pax_global_header00006660000000000000000000000064146061625360014523gustar00rootroot0000000000000052 comment=6555b9549630cf37f41ac44627fe702c6622a0b3 view-6.26.3/000077500000000000000000000000001460616253600125735ustar00rootroot00000000000000view-6.26.3/.github/000077500000000000000000000000001460616253600141335ustar00rootroot00000000000000view-6.26.3/.github/workflows/000077500000000000000000000000001460616253600161705ustar00rootroot00000000000000view-6.26.3/.github/workflows/dispatch.yml000066400000000000000000000006371460616253600205200ustar00rootroot00000000000000name: Trigger CI on: push jobs: build: name: Dispatch to main repo runs-on: ubuntu-latest steps: - name: Emit repository_dispatch uses: mvasigh/dispatch-action@main with: # You should create a personal access token and store it in your repository token: ${{ secrets.DISPATCH_AUTH }} repo: dev owner: codemirror event_type: push view-6.26.3/.gitignore000066400000000000000000000001271460616253600145630ustar00rootroot00000000000000/node_modules package-lock.json /dist /test/*.js /test/*.d.ts /test/*.d.ts.map .tern-* view-6.26.3/.npmignore000066400000000000000000000001001460616253600145610ustar00rootroot00000000000000/src /test /node_modules .tern-* rollup.config.js tsconfig.json view-6.26.3/CHANGELOG.md000066400000000000000000001646331460616253600144210ustar00rootroot00000000000000## 6.26.3 (2024-04-12) ### Bug fixes Fix an issue where dispatching an update to an editor before it measured itself for the first time could cause the scroll position to incorrectly move. Fix a crash when multiple tooltips with arrows are shown. ## 6.26.2 (2024-04-09) ### Bug fixes Improve behavior of `scrollPastEnd` in a scaled editor. When available, use `Selection.getComposedRanges` on Safari to find the selection inside a shadow DOM. Remove the workaround that avoided inappropriate styling on composed text after a decoration again, since it breaks the stock Android virtual keyboard. ## 6.26.1 (2024-03-28) ### Bug fixes Fix the editor getting stuck in composition when Safari fails to fire a compositionend event for a dead key composition. Fix an issue where, with IME systems that kept the cursor at the start of the composed text, the editor misidentified the target node and disrupted composition. Fix a bug where in a line-wrapped editor, with some content, the initial scroll position would be off from the top of the document. ## 6.26.0 (2024-03-14) ### Bug fixes Avoid the editor getting confused when iOS autocorrects on pressing Enter and does the correction and the break insertion in two different events. Fix the pasting of copied URIs in iOS. Fix a bug where a scaled editor could keep performing unnecessary updates due to tiny differences in geometry values returned by the browser. Fix a bug where, on iOS with a physical keyboard, the modifiers for some keys weren't being passed to the keymaps. Work around the fact that Mobile Safari makes DOM changes before firing a key event when typing ctrl-d on an external keyboard. Fix an issue where some commands didn't properly scroll the cursor into view on Mobile Safari. Re-measure the document when print settings are changed on Chrome. ### New features The `EditorView.scrollHandler` facet can be used to override or extend the behavior of the editor when things are scrolled into view. ## 6.25.1 (2024-03-06) ### Bug fixes Fix accidental non-optional field in layer config objects. ## 6.25.0 (2024-03-04) ### Bug fixes Properly recognize Android GBoard enter presses that strip a space at the end of the line as enter. Fix a bug that caused the gutter to have the wrong height when the editor was scaled after construction. When starting a composition after a non-inclusive mark decoration, temporarily insert a widget that prevents the composed text from inheriting that mark's styles. Make sure the selection is repositioned when a transaction changes decorations without changing the document. ### New features View plugins can now provide a `docViewUpdate` method that is called whenever the document view is updated. Layers now take a `updateOnDocUpdate` option that controls whether they are automatically updated when the document view changes. ## 6.24.1 (2024-02-19) ### Bug fixes Fix a crash that happens when hover tooltips are active during changes, introduced in 6.24.0. ## 6.24.0 (2024-02-09) ### Bug fixes Fix an issue that broke context-menu select-all on Chrome when the viewport didn't cover the whole document. Make sure tooltips are ordered by extension precedence in the DOM. ### New features Hover tooltip sources may now return multiple tooltips. ## 6.23.1 (2024-01-24) ### Bug fixes Fix a bug that caused `Tooltip.above` to not take effect for tooltips that were already present when the tooltip plugin is initialized. Automatically reposition tooltips when their size changes. ## 6.23.0 (2023-12-28) ### Bug fixes Work around odd iOS Safari behavior when doing select all. Fix a composition interruption when an widget is inserted next to the cursor. Fix a crash in bidirectional cursor motion. Simplify visual motion through bidirectional text, fix several corner cases where it would work badly. Fix a bug that broke some bidi isolates not on the first line of the document. ### New features `EditorView.bidiIsolatedRanges` now supports automatically determining the direction of the range if not provided by the decoration. `EditorView.visualLineSide` can be used to find the visual end or start of a line with bidirectional text. The new `EditorView.outerDecorations` facet can be used to provide decorations that should always be at the bottom of the precedence stack. ## 6.22.3 (2023-12-13) ### Bug fixes Fix a bug that could cause tooltips to be unnecessarily be positioned absolutely. Make sure that, when an editor creates tooltips immediately on initialization, the editor is attached to the document when their `mount` callback is called. ## 6.22.2 (2023-12-08) ### Bug fixes Fix an issue in the bidirectional motion that could cause the cursor to get stuck in a loop when a zero-width non-joiner char was placed on a direction boundary. Fix a bug that corrupts the editor's internal view tree data structure on some types of edits, putting the editor in a broken state. ## 6.22.1 (2023-11-27) ### Bug fixes Call widget `destroy` methods when the entire editor is destroyed or reset. Work around an issue on Safari on macOS Sonoma that made the native cursor visible even when `drawSelection` is enabled. Fix an issue where, on some browsers, the screenreader announced text ended up in the printed document. Fix a bug where a hover tooltip could stick around even though the pointer was no longer on the editor when it was moved out over the tooltip. Fix an issue where hover tooltips could close when moving the mouse onto them due to mouse position rounding issues. ## 6.22.0 (2023-11-03) ### Bug fixes Exceptions raised by update listeners are now routed to the configured exception sink, if any. Fix an issue where passing large scroll margins to `scrollIntoView` would cause the measure loop to fail to terminate. Widgets that are draggable (and allow drag events through in their `ignoreEvent` implementation) can now use the editor's built-in drag/drop behavior. ### New features The new `scrollTo` option to `EditorView` allows an initial scroll position to be provided. The new `EditorView.scrollSnapshot` method returns an effect that can be used to reset to a previous scroll position. ## 6.21.4 (2023-10-24) ### Bug fixes Support the `offset`, `getCoords`, `overlap`, and `resize` properties on hover tooltips, as long as they aren't given conflicting values when there are multiple active hover tooltips. Fix a bug that caused tooltips in the default configuration to be positioned incorrectly on Chrome when the editor was transformed. ## 6.21.3 (2023-10-06) ### Bug fixes Fix an issue that caused `coordsForChar` to return the wrong rectangle for characters after a line wrap in Safari. Make the context menu work when clicking below the content in a fixed-height editor. Tooltips that have been put below/above their target position because there is no room on their default side now stay there on further updates. ## 6.21.2 (2023-10-02) ### Bug fixes Fix a regression that broke dragging text from inside the editor. ## 6.21.1 (2023-10-02) ### Bug fixes Fix a bug that could corrupt the DOM view for specific changes involving newlines and mark decorations. ## 6.21.0 (2023-09-29) ### Bug fixes Fix a bug that could cause zero-length widgets at the start of a line to be left in the view even after they were removed. ### New features `RectangleMarker`'s dimension properties are now public. ## 6.20.2 (2023-09-25) ### Bug fixes Fix an issue in the way the DOM selection is being read that could break backspacing of widgets on Android. Fix a bug where the editor could incorrectly computate its transform scale when it was small. ## 6.20.1 (2023-09-22) ### Bug fixes Fix a crash in plugin event handlers after dynamic reconfiguration. Fix an issue where, on Chrome, tooltips would no longer use fixed positioning. ## 6.20.0 (2023-09-20) ### Bug fixes Fix an issue that caused `repositionTooltips` to crash when it was called on an editor without tooltips. Fix an issue that caused the tooltip system to leave empty nodes in the DOM when an editor using the `parent` option to `tooltips` is destroyed. Fix a bug that regression mouse interaction with the area of a fixed-size editor that isn't covered by the content. Fix some issues with the way `moveVertically` behaved for positions on line wrap points. Fix a bug that could cause the document DOM to be incorrectly updated on some types of viewport changes. ### New features The new `getDrawSelectionConfig` function returns the `drawSelection` configuration for a given state. ## 6.19.0 (2023-09-14) ### Bug fixes Make sure the drop cursor is properly cleaned up even when another extension handles the drop event. Fix a crash related to non-inclusive replacing block decorations. ### New features The new `EditorView.domEventObservers` (and the corresponding option to view plugins) allows you to register functions that are always called for an event, regardless of whether other handlers handled it. ## 6.18.1 (2023-09-11) ### Bug fixes Fix an issue where the editor duplicated text when the browser moved content into the focused text node on composition. Make sure `widgetMarker` is called for gutters on lines covered by a block replace decoration. Fix an issue where the cursor could be shown in a position that doesn't allow a cursor when the selection is in a block widget. ## 6.18.0 (2023-09-05) ### New features The new `EditorView.scaleX` and `scaleY` properties return the CSS-transformed scale of the editor (or 1 when not scaled). The editor now supports being scaled with CSS. ## 6.17.1 (2023-08-31) ### Bug fixes Don't close the hover tooltip when the pointer moves over empty space caused by line breaks within the hovered range. Fix a bug where on Chrome Android, if a virtual keyboard was slow to apply a change, the editor could end up dropping it. Work around an issue where line-wise copy/cut didn't work in Firefox because the browser wasn't firing those events when nothing was selected. Fix a crash triggered by the way some Android IME systems update the DOM. Fix a bug that caused replacing a word by an emoji on Chrome Android to be treated as a backspace press. ## 6.17.0 (2023-08-28) ### Bug fixes Fix a bug that broke hover tooltips when hovering over a widget. ### New features The new `EditorView.cspNonce` facet can be used to provide a Content Security Policy nonce for the library's generated CSS. The new `EditorView.bidiIsolatedRanges` can be used to inform the editor about ranges styled as Unicode bidirection isolates, so that it can compute the character order correctly. `EditorView.dispatch` now also accepts an array of transactions to be applied together in a single view update. The new `dispatchTransactions` option to `new EditorView` now replaces the old (deprecated but still supported) `dispatch` option in a way that allows multiple transactions to be applied in one update. Input handlers are now passed an additional argument that they can use to retrieve the default transaction that would be applied for the insertion. ## 6.16.0 (2023-07-31) ### Bug fixes Fix an issue that made the gutter not stick in place when the editor was in a right-to-left context. ### New features The new `EditorView.coordsForChar` method returns the client rectangle for a given character in the editor. ## 6.15.3 (2023-07-18) ### Bug fixes Fix another crash regression for compositions before line breaks. ## 6.15.2 (2023-07-18) ### Bug fixes Fix the check that made sure compositions are dropped when the selection is moved. ## 6.15.1 (2023-07-18) ### Bug fixes Fix a regression that could cause the composition content to be drawn incorrectly. ## 6.15.0 (2023-07-17) ### Bug fixes Fix dragging a selection from inside the current selection on macOS. Fix an issue that could cause the scroll position to jump wildly Don't try to scroll fixed-positioned elements into view by scrolling their parent elements. Fix a bug that caused the cursor to be hidden when showing a placeholder that consisted of the empty string. Resolve some issues where composition could incorrectly affect nearby replaced content. ### New features Key bindings can now set a `stopPropagation` field to cause the view to stop the key event propagation when it considers the event handled. ## 6.14.1 (2023-07-06) ### Bug fixes Fix an issue where scrolling up through line-wrapped text would sometimes cause the scroll position to pop down. Fix an issue where clicking wouldn't focus the editor on Firefox when it was in an iframe and already the active element of the frame. Fix a bug that could cause compositions to be disrupted because their surrounding DOM was repurposed for some other piece of content. Fix a bug where adding content to the editor could inappropriately move the scroll position. Extend detection of Enter presses on Android to `beforeInput` events with an `"insertLineBreak"` type. ## 6.14.0 (2023-06-23) ### Bug fixes When dragging text inside the editor, look at the state of Ctrl (or Alt on macOS) at the time of the drop, not the start of drag, to determine whether to move or copy the text. Fix an issue where having a bunch of padding on lines could cause vertical cursor motion and `posAtCoords` to jump over lines. ### New features Block widget decorations can now be given an `inlineOrder` option to make them appear in the same ordering as surrounding inline widgets. ## 6.13.2 (2023-06-13) ### Bug fixes Fix an issue in scroll position stabilization for changes above the visible, where Chrome already does this natively and we ended up compensating twice. ## 6.13.1 (2023-06-12) ### Bug fixes Fix a bug where the cursor would in some circumstances be drawn on the wrong side of an inline widget. Fix an issue where `scrollPastEnd` could cause the scroll position of editors that weren't in view to be changed unnecessarily. ## 6.13.0 (2023-06-05) ### Bug fixes Forbid widget decoration side values bigger than 10000, to prevent them from breaking range ordering invariants. Fix a bug where differences between widgets' estimated and actual heights could cause the editor to inappropriately move the scroll position. Avoid another situation in which composition that inserts line breaks could corrupt the editor DOM. ### New features Inline widgets may now introduce line breaks, if they report this through the `WidgetType.lineBreaks` property. ## 6.12.0 (2023-05-18) ### Bug fixes Remove an accidentally included `console.log`. ### New features `EditorViewConfig.dispatch` is now passed the view object as a second argument. ## 6.11.3 (2023-05-17) ### Bug fixes Make sure pointer selection respects `EditorView.atomicRanges`. Preserve DOM widgets when their decoration type changes but they otherwise stay in the same place. Fix a bug in `drawSelection` that could lead to invisible or incorrect selections for a blank line below a block widget. ## 6.11.2 (2023-05-13) ### Bug fixes Fix a bug where the `crosshairCursor` extension could, when non-native key events were fired, trigger disruptive and needless view updates. Fix an Android issue where backspacing at the front of a line with widget decorations could replace those decorations with their text content. Respect scroll margins when scrolling the target of drag-selection into view. Validate selection offsets reported by the browser, to work around Safari giving us invalid values in some cases. ## 6.11.1 (2023-05-09) ### Bug fixes Don't preserve the DOM around a composition that spans multiple lines. ## 6.11.0 (2023-05-03) ### New features Gutters now support a `widgetMarker` option that can be used to add markers next to block widgets. ## 6.10.1 (2023-05-01) ### Bug fixes Limit cursor height in front of custom placeholder DOM elements. ## 6.10.0 (2023-04-25) ### Bug fixes Fix a crash in `drawSelection` when a measured position falls on a position that doesn't have corresponding screen coordinates. Work around unhelpful interaction observer behavior that could cause the editor to not notice it was visible. Give the cursor next to a line-wrapped placeholder a single-line height. Make sure drop events below the editable element in a fixed-height editor get handled properly. ### New features Widget decorations can now define custom `coordsAtPos` methods to control the way the editor computes screen positions at or in the widget. ## 6.9.6 (2023-04-21) ### Bug fixes Fix an issue where, when escape was pressed followed by a key that the editor handled, followed by tab, the tab would still move focus. Fix an issue where, in some circumstances, the editor would ignore text changes at the end of a composition. Allow inline widgets to be updated to a different length via `updateDOM`. ## 6.9.5 (2023-04-17) ### Bug fixes Avoid disrupting the composition in specific cases where Safari invasively changes the DOM structure in the middle of a composition. Fix a bug that prevented `destroy` being called on hover tooltips. Fix a bug where the editor could take focus when content changes required it to restore the DOM selection. Fix height layout corruption caused by a division by zero. Make sure styles targeting the editor's focus status are specific enough to not cause them to apply to editors nested inside another focused editor. This will require themes to adjust their selection background styles to match the new specificity. ## 6.9.4 (2023-04-11) ### Bug fixes Make the editor scroll while dragging a selection near its sides, even if the cursor isn't outside the scrollable element. Fix a bug that interrupted composition after widgets in some circumstances on Firefox. Make sure the last change in a composition has its user event set to `input.type.compose`, even if the `compositionend` event fires before the changes are applied. Make it possible to remove additional selection ranges by clicking on them with ctrl/cmd held, even if they aren't cursors. Keep widget buffers between widgets and compositions, since removing them confuses IME on macOS Firefox. Fix a bug where, for DOM changes that put the selection in the middle of the changed range, the editor incorrectly set its selection state. Fix a bug where `coordsAtPos` could return a coordinates before the line break when querying a line-wrapped position with a positive `side`. ## 6.9.3 (2023-03-21) ### Bug fixes Work around a Firefox issue that caused `coordsAtPos` to return rectangles with the full line height on empty lines. Opening a context menu by clicking below the content element but inside the editor now properly shows the browser's menu for editable elements. Fix an issue that broke composition (especially of Chinese IME) after widget decorations. Fix an issue that would cause the cursor to jump around during compositions inside nested mark decorations. ## 6.9.2 (2023-03-08) ### Bug fixes Work around a Firefox CSS bug that caused cursors to stop blinking in a scrolled editor. Fix an issue in `drawSelection` where the selection extended into the editor's padding. Fix pasting of links copied from iOS share sheet. ## 6.9.1 (2023-02-17) ### Bug fixes Improve the way `posAtCoords` picks the side of a widget to return by comparing the coordinates the center of the widget. Fix an issue where transactions created for the `focusChangeEffect` facet were sometimes not dispatched. ## 6.9.0 (2023-02-15) ### Bug fixes Fix an issue where inaccurate estimated vertical positions could cause the viewport to not converge in line-wrapped editors. Don't suppress double-space to period conversion when autocorrect is enabled. Make sure the measuring code notices when the scaling of the editor is changed, and does a full measure in that case. ### New features The new `EditorView.focusChangeEffect` facet can be used to dispatch a state effect when the editor is focused or blurred. ## 6.8.1 (2023-02-08) ### Bug fixes Fix an issue where tooltips that have their height reduced have their height flicker when scrolling or otherwise interacting with the editor. ## 6.8.0 (2023-02-07) ### Bug fixes Fix a regression that caused clicking on the scrollbar to move the selection. Fix an issue where focus or blur event handlers that dispatched editor transactions could corrupt the mouse selection state. Fix a CSS regression that prevented the drop cursor from being positioned properly. ### New features `WidgetType.updateDOM` is now passed the editor view object. ## 6.7.3 (2023-01-12) ### Bug fixes Fix a bug in `posAtCoords` that could cause incorrect results for positions to the left of a wrapped line. ## 6.7.2 (2023-01-04) ### Bug fixes Fix a regression where the cursor didn't restart its blink cycle when moving it with the pointer. Even without a `key` property, measure request objects that are already scheduled will not be scheduled again by `requestMeasure`. Fix an issue where keymaps incorrectly interpreted key events that used Ctrl+Alt modifiers to simulate AltGr on Windows. Fix a bug where line decorations with a different `class` property would be treated as equal. Fix a bug that caused `drawSelection` to not notice when it was reconfigured. Fix a crash in the gutter extension caused by sharing of mutable arrays. Fix a regression that caused touch selection on mobile platforms to not work in an uneditable editor. Fix a bug where DOM events on the boundary between lines could get assigned to the wrong line. ## 6.7.1 (2022-12-12) ### Bug fixes Make the editor properly scroll when moving the pointer out of it during drag selection. Fix a regression where clicking below the content element in an editor with its own height didn't focus the editor. ## 6.7.0 (2022-12-07) ### Bug fixes Make the editor notice widget height changes to automatically adjust its height information. Fix an issue where widget buffers could be incorrectly omitted after empty lines. Fix an issue in content redrawing that could cause `coordsAtPos` to return incorrect results. ### New features The static `RectangleMarker.forRange` method exposes the logic used by the editor to draw rectangles covering a selection range. Layers can now provide a `destroy` function to be called when the layer is removed. The new `highlightWhitespace` extension makes spaces and tabs in the editor visible. The `highlightTrailingWhitespace` extension can be used to make trailing whitespace stand out. ## 6.6.0 (2022-11-24) ### New features The `layer` function can now be used to define extensions that draw DOM elements over or below the document text. Tooltips that are bigger than the available vertical space for them will now have their height set so that they don't stick out of the window. The new `resize` property on `TooltipView` can be used to opt out of this behavior. ## 6.5.1 (2022-11-15) ### Bug fixes Fix a bug that caused marked unnecessary splitting of mark decoration DOM elements in some cases. ## 6.5.0 (2022-11-14) ### Bug fixes Fix an issue where key bindings were activated for the wrong key in some situations with non-US keyboards. ### New features A tooltip's `positioned` callback is now passed the available space for tooltips. ## 6.4.2 (2022-11-10) ### Bug fixes Typing into a read-only editor no longer moves the cursor. Fix an issue where hover tooltips were closed when the mouse was moved over them if they had a custom parent element. Fix an issue where the editor could end up displaying incorrect height measurements (typically after initializing). ## 6.4.1 (2022-11-07) ### Bug fixes Fix an issue where coordinates next to replaced widgets were returned incorrectly, causing the cursor to be drawn in the wrong place. Update the `crosshairCursor` state on every mousemove event. Avoid an issue in the way that the editor enforces cursor associativity that could cause the cursor to get stuck on single-character wrapped lines. ## 6.4.0 (2022-10-18) ### Bug fixes Avoid an issue where `scrollPastEnd` makes a single-line editor have a vertical scrollbar. Work around a Chrome bug where it inserts a newline when you press space at the start of a wrapped line. Align `rectangularSelection`'s behavior with other popular editors by making it create cursors at the end of lines that are too short to touch the rectangle. Fix an issue where coordinates on mark decoration boundaries were sometimes taken from the wrong side of the position. Prevent scrolling artifacts caused by attempts to scroll stuff into view when the editor isn't being displayed. ### New features `TooltipView` objects can now provide a `destroy` method to be called when the tooltip is removed. ## 6.3.1 (2022-10-10) ### Bug fixes Fix a crash when trying to scroll something into view in an editor that wasn't in the visible DOM. Fix an issue where `coordsAtPos` returned the coordinates on the wrong side of a widget decoration wrapped in a mark decoration. Fix an issue where content on long wrapped lines could fail to properly scroll into view. Fix an issue where DOM change reading on Chrome Android could get confused when a transaction came in right after a beforeinput event for backspace, enter, or delete. ## 6.3.0 (2022-09-28) ### Bug fixes Reduce the amount of wrap-point jittering when scrolling through a very long wrapped line. Fix an issue where scrolling to content that wasn't currently drawn due to being on a very long line would often fail to scroll to the right position. Suppress double-space-adds-period behavior on Chrome Mac when it behaves weirdly next to widget. ### New features Key binding objects with an `any` property will now add handlers that are called for any key, within the ordering of the keybindings. ## 6.2.5 (2022-09-24) ### Bug fixes Don't override double/triple tap behavior on touch screen devices, so that the mobile selection menu pops up properly. Fix an issue where updating the selection could crash on Safari when the editor was hidden. ## 6.2.4 (2022-09-16) ### Bug fixes Highlight the active line even when there is a selection. Prevent the active line background from obscuring the selection backdrop. Fix an issue where elements with negative margins would confuse the editor's scrolling-into-view logic. Fix scrolling to a specific position in an editor that has not been in view yet. ## 6.2.3 (2022-09-08) ### Bug fixes Fix a bug where cursor motion, when starting from a non-empty selection range, could get stuck on atomic ranges in some circumstances. Avoid triggering Chrome Android's text-duplication issue when a period is typed in the middle of a word. ## 6.2.2 (2022-08-31) ### Bug fixes Don't reset the selection for selection change events that were suppressed by a node view. ## 6.2.1 (2022-08-25) ### Bug fixes Don't use the global `document` variable to track focus, since that doesn't work in another window/frame. Fix an issue where key handlers that didn't return true were sometimes called twice for the same keypress. Avoid editing glitches when using deletion keys like ctrl-d on iOS. Properly treat characters from the 'Arabic Presentation Forms-A' Unicode block as right-to-left. Work around a Firefox bug that inserts text at the wrong point for specific cross-line selections. ## 6.2.0 (2022-08-05) ### Bug fixes Fix a bug where `posAtCoords` would return the wrong results for positions to the right of wrapped lines. ### New features The new `EditorView.setRoot` method can be used when an editor view is moved to a new document or shadow root. ## 6.1.4 (2022-08-04) ### Bug fixes Make selection-restoration on focus more reliable. ## 6.1.3 (2022-08-03) ### Bug fixes Fix a bug where a document that contains only non-printing characters would lead to bogus text measurements (and, from those, to crashing). Make sure differences between estimated and actual block heights don't cause visible scroll glitches. ## 6.1.2 (2022-07-27) ### Bug fixes Fix an issue where double tapping enter to confirm IME input and insert a newline on iOS would sometimes insert two newlines. Fix an issue on iOS where a composition could get aborted if the editor scrolled on backspace. ## 6.1.1 (2022-07-25) ### Bug fixes Make `highlightSpecialChars` replace directional isolate characters by default. The editor will now try to suppress browsers' native behavior of resetting the selection in the editable content when the editable element is focused (programmatically, with tab, etc). Fix a CSS issue that made it possible, when the gutters were wide enough, for them to overlap with the content. ## 6.1.0 (2022-07-19) ### New features `MatchDecorator` now supports a `decorate` option that can be used to customize the way decorations are added for each match. ## 6.0.3 (2022-07-08) ### Bug fixes Fix a problem where `posAtCoords` could incorrectly return the start of the next line when querying positions between lines. Fix an issue where registering a high-precedence keymap made keymap handling take precedence over other keydown event handlers. Ctrl/Cmd-clicking can now remove ranges from a multi-range selection. ## 6.0.2 (2022-06-23) ### Bug fixes Fix a CSS issue that broke horizontal scroll width stabilization. Fix a bug where `defaultLineHeight` could get an incorrect value in very narrow editors. ## 6.0.1 (2022-06-17) ### Bug fixes Avoid DOM selection corruption when the editor doesn't have focus but has selection and updates its content. Fall back to dispatching by key code when a key event produces a non-ASCII character (so that Cyrillic and Arabic keyboards can still use bindings specified with Latin characters). ## 6.0.0 (2022-06-08) ### New features The new static `EditorView.findFromDOM` method can be used to retrieve an editor instance from its DOM structure. Instead of passing a constructed state to the `EditorView` constructor, it is now also possible to inline the configuration options to the state in the view config object. ## 0.20.7 (2022-05-30) ### Bug fixes Fix an issue on Chrome Android where the DOM could fail to display the actual document after backspace. Avoid an issue on Chrome Android where DOM changes were sometimes inappropriately replace by a backspace key effect due to spurious beforeinput events. Fix a problem where the content element's width didn't cover the width of the actual content. Work around a bug in Chrome 102 which caused wheel scrolling of the editor to be interrupted every few lines. ## 0.20.6 (2022-05-20) ### Bug fixes Make sure the editor re-measures itself when its attributes are updated. ## 0.20.5 (2022-05-18) ### Bug fixes Fix an issue where gutter elements without any markers in them would not get the `cm-gutterElement` class assigned. Fix an issue where DOM event handlers registered by plugins were retained indefinitely, even after the editor was reconfigured. ## 0.20.4 (2022-05-03) ### Bug fixes Prevent Mac-style behavior of inserting a period when the user inserts two spaces. Fix an issue where the editor would sometimes not restore the DOM selection when refocused with a selection identical to the one it held when it lost focus. ## 0.20.3 (2022-04-27) ### Bug fixes Fix a bug where the input handling could crash on repeated (or held) backspace presses on Chrome Android. ## 0.20.2 (2022-04-22) ### New features The new `hideOn` option to `hoverTooltip` allows more fine-grained control over when the tooltip should hide. ## 0.20.1 (2022-04-20) ### Bug fixes Remove debug statements that accidentally made it into 0.20.0. Fix a regression in `moveVertically`. ## 0.20.0 (2022-04-20) ### Breaking changes The deprecated interfaces `blockAtHeight`, `visualLineAtHeight`, `viewportLines`, `visualLineAt`, `scrollPosIntoView`, `scrollTo`, and `centerOn` were removed from the library. All decorations are now provided through `EditorView.decorations`, and are part of a single precedence ordering. Decoration sources that need access to the view are provided as functions. Atomic ranges are now specified through a facet (`EditorView.atomicRanges`). Scroll margins are now specified through a facet (`EditorView.scrollMargins`). Plugin fields no longer exist in the library (and are replaced by facets holding function values). This package no longer re-exports the Range type from @codemirror/state. ### Bug fixes Fix a bug where zero-length block widgets could cause `viewportLineBlocks` to contain overlapping ranges. ### New features The new `perLineTextDirection` facet configures whether the editor reads text direction per line, or uses a single direction for the entire editor. `EditorView.textDirectionAt` returns the direction around a given position. `rectangularSelection` and `crosshairCursor` from @codemirror/rectangular-selection were merged into this package. This package now exports the tooltip functionality that used to live in @codemirror/tooltip. The exports from the old @codemirror/panel package are now available from this package. The exports from the old @codemirror/gutter package are now available from this package. ## 0.19.48 (2022-03-30) ### Bug fixes Fix an issue where DOM syncing could crash when a DOM node was moved from a parent to a child node (via widgets reusing existing nodes). To avoid interfering with things like a vim mode too much, the editor will now only activate the tab-to-move-focus escape hatch after an escape press that wasn't handled by an event handler. Make sure the view measures itself before the page is printed. Tweak types of view plugin defining functions to avoid TypeScript errors when the plugin value doesn't have any of the interface's properties. ## 0.19.47 (2022-03-08) ### Bug fixes Fix an issue where block widgets at the start of the viewport could break height computations. ## 0.19.46 (2022-03-03) ### Bug fixes Fix a bug where block widgets on the edges of viewports could cause the positioning of content to misalign with the gutter and height computations. Improve cursor height next to widgets. Fix a bug where mapping positions to screen coordinates could return incorred coordinates during composition. ## 0.19.45 (2022-02-23) ### Bug fixes Fix an issue where the library failed to call `WidgetType.destroy` on the old widget when replacing a widget with a different widget of the same type. Fix an issue where the editor would compute DOM positions inside composition contexts incorrectly in some cases, causing the selection to be put in the wrong place and needlessly interrupting compositions. Fix leaking of resize event handlers. ## 0.19.44 (2022-02-17) ### Bug fixes Fix a crash that occasionally occurred when drag-selecting in a way that scrolled the editor. ### New features The new `EditorView.compositionStarted` property indicates whether a composition is starting. ## 0.19.43 (2022-02-16) ### Bug fixes Fix several issues where editing or composition went wrong due to our zero-width space kludge characters ending up in unexpected places. Make sure the editor re-measures its dimensions whenever its theme changes. Fix an issue where some keys on Android phones could leave the editor DOM unsynced with the actual document. ## 0.19.42 (2022-02-05) ### Bug fixes Fix a regression in cursor position determination after making an edit next to a widget. ## 0.19.41 (2022-02-04) ### Bug fixes Fix an issue where the editor's view of its content height could go out of sync with the DOM when a line-wrapping editor had its width changed, causing wrapping to change. Fix a bug that caused the editor to draw way too much content when scrolling to a position in an editor (much) taller than the window. Report an error when a replace decoration from a plugin crosses a line break, rather than silently ignoring it. Fix an issue where reading DOM changes was broken when `lineSeparator` contained more than one character. Make ordering of replace and mark decorations with the same extent and inclusivness more predictable by giving replace decorations precedence. Fix a bug where, on Chrome, replacement across line boundaries and next to widgets could cause bogus zero-width characters to appear in the content. ## 0.19.40 (2022-01-19) ### Bug fixes Make composition input properly appear at secondary cursors (except when those are in the DOM node with the composition, in which case the browser won't allow us to intervene without aborting the composition). Fix a bug that cause the editor to get confused about which content was visible after scrolling something into view. Fix a bug where the dummy elements rendered around widgets could end up in a separate set of wrapping marks, and thus become visible. `EditorView.moveVertically` now preserves the `assoc` property of the input range. Get rid of gaps between selection elements drawn by `drawSelection`. Fix an issue where replacing text next to a widget might leak bogus zero-width spaces into the document. Avoid browser selection mishandling when a focused view has `setState` called by eagerly refocusing it. ## 0.19.39 (2022-01-06) ### Bug fixes Make sure the editor signals a `geometryChanged` update when its width changes. ### New features `EditorView.darkTheme` can now be queried to figure out whether the editor is using a dark theme. ## 0.19.38 (2022-01-05) ### Bug fixes Fix a bug that caused line decorations with a `class` property to suppress all other line decorations for that line. Fix a bug that caused scroll effects to be corrupted when further updates came in before they were applied. Fix an issue where, depending on which way a floating point rounding error fell, `posAtCoords` (and thus vertical cursor motion) for positions outside of the vertical range of the document might or might not return the start/end of the document. ## 0.19.37 (2021-12-22) ### Bug fixes Fix regression where plugin replacing decorations that span to the end of the line are ignored. ## 0.19.36 (2021-12-22) ### Bug fixes Fix a crash in `posAtCoords` when the position lies in a block widget that is rendered but scrolled out of view. Adding block decorations from a plugin now raises an error. Replacing decorations that cross lines are ignored, when provided by a plugin. Fix inverted interpretation of the `precise` argument to `posAtCoords`. ## 0.19.35 (2021-12-20) ### Bug fixes The editor will now handle double-taps as if they are double-clicks, rather than letting the browser's native behavior happen (because the latter often does the wrong thing). Fix an issue where backspacing out a selection on Chrome Android would sometimes only delete the last character due to event order issues. `posAtCoords`, without second argument, will no longer return null for positions below or above the document. ## 0.19.34 (2021-12-17) ### Bug fixes Fix a bug where content line elements would in some cases lose their `cm-line` class. ## 0.19.33 (2021-12-16) ### Breaking changes `EditorView.scrollTo` and `EditorView.centerOn` are deprecated in favor of `EditorView.scrollIntoView`, and will be removed in the next breaking release. ### Bug fixes Fix an issue that could cause the editor to unnecessarily interfere with composition (especially visible on macOS Chrome). A composition started with multiple lines selected will no longer be interruptd by the editor. ### New features The new `EditorView.scrollIntoView` function allows you to do more fine-grained scrolling. ## 0.19.32 (2021-12-15) ### Bug fixes Fix a bug where CodeMirror's own event handers would run even after a user-supplied handler called `preventDefault` on an event. Properly draw selections when negative text-indent is used for soft wrapping. Fix an issue where `viewportLineBlocks` could hold inaccurate height information when the vertical scaling changed. Fixes drop cursor positioning when the document is scrolled. Force a content measure when the editor comes into view Fix a bug that could cause the editor to not measure its layout the first time it came into view. ## 0.19.31 (2021-12-13) ### New features The package now exports a `dropCursor` extension that draws a cursor at the current drop position when dragging content over the editor. ## 0.19.30 (2021-12-13) ### Bug fixes Refine Android key event handling to work properly in a GBoard corner case where pressing Enter fires a bunch of spurious deleteContentBackward events. Fix a crash in `drawSelection` for some kinds of selections. Prevent a possibility where some content updates causes duplicate text to remain in DOM. ### New features Support a `maxLength` option to `MatchDecorator` that allows user code to control how far it scans into hidden parts of viewport lines. ## 0.19.29 (2021-12-09) ### Bug fixes Fix a bug that could cause out-of-view editors to get a nonsensical viewport and fail to scroll into view when asked to. Fix a bug where would return 0 when clicking below the content if the last line was replaced with a block widget decoration. Fix an issue where clicking at the position of the previous cursor in a blurred editor would cause the selection to reset to the start of the document. Fix an issue where composition could be interrupted if the browser created a new node inside a mark decoration node. ## 0.19.28 (2021-12-08) ### Bug fixes Fix an issue where pressing Enter on Chrome Android during composition did not fire key handlers for Enter. Avoid a Chrome bug where the virtual keyboard closes when pressing backspace after a widget. Fix an issue where the editor could show a horizontal scroll bar even after all lines that caused it had been deleted or changed. ## 0.19.27 (2021-12-06) ### Bug fixes Fix a bug that could cause `EditorView.plugin` to inappropriately return `null` during plugin initialization. Fix a bug where a block widget without `estimatedHeight` at the end of the document could fail to be drawn ## 0.19.26 (2021-12-03) ### New features Widgets can now define a `destroy` method that is called when they are removed from the view. ## 0.19.25 (2021-12-02) ### Bug fixes Widgets around replaced ranges are now visible when their side does not point towards the replaced range. A replaced line with a line decoration no longer creates an extra empty line block in the editor. The `scrollPastEnd` extension will now over-reserve space at the bottom of the editor on startup, to prevent restored scroll positions from being clipped. ### New features `EditorView.editorAttributes` and `contentAttributes` may now hold functions that produce the attributes. ## 0.19.24 (2021-12-01) ### Bug fixes Fix a bug where `lineBlockAt`, for queries inside the viewport, would always return the first line in the viewport. ## 0.19.23 (2021-11-30) ### Bug fixes Fix an issue where after some kinds of changes, `EditorView.viewportLineBlocks` held an out-of-date set of blocks. ### New features Export `EditorView.documentPadding`, with information about the vertical padding of the document. ## 0.19.22 (2021-11-30) ### Bug fixes Fix an issue where editors with large vertical padding (for example via `scrollPastEnd`) could sometimes lose their scroll position on Chrome. Avoid some unnecessary DOM measuring work by more carefully checking whether it is needed. ### New features The new `elementAtHeight`, `lineBlockAtHeight`, and `lineBlockAt` methods provide a simpler and more efficient replacement for the (now deprecated) `blockAtHeight`, `visualLineAtHeight`, and `visualLineAt` methods. The editor view now exports a `documentTop` getter that gives you the vertical position of the top of the document. All height info is queried and reported relative to this top. The editor view's new `viewportLineBlocks` property provides an array of in-viewport line blocks, and replaces the (now deprecated) `viewportLines` method. ## 0.19.21 (2021-11-26) ### Bug fixes Fix a problem where the DOM update would unnecessarily trigger browser relayouts. ## 0.19.20 (2021-11-19) ### Bug fixes Run a measure cycle when the editor's size spontaneously changes. ## 0.19.19 (2021-11-17) ### Bug fixes Fix a bug that caused the precedence of `editorAttributes` and `contentAttributes` to be inverted, making lower-precedence extensions override higher-precedence ones. ## 0.19.18 (2021-11-16) ### Bug fixes Fix an issue where the editor wasn't aware it was line-wrapping with its own `lineWrapping` extension enabled. ## 0.19.17 (2021-11-16) ### Bug fixes Avoid an issue where stretches of whitespace on line wrap points could cause the cursor to be placed outside of the content. ## 0.19.16 (2021-11-11) ### Breaking changes Block replacement decorations now default to inclusive, because non-inclusive block decorations are rarely what you need. ### Bug fixes Fix an issue that caused block widgets to always have a large side value, making it impossible to show them between to replacement decorations. Fix a crash that could happen after some types of viewport changes, due to a bug in the block widget view data structure. ## 0.19.15 (2021-11-09) ### Bug fixes Fix a bug where the editor would think it was invisible when the document body was given screen height and scroll behavior. Fix selection reading inside a shadow root on iOS. ## 0.19.14 (2021-11-07) ### Bug fixes Fix an issue where typing into a read-only editor would move the selection. Fix slowness when backspace is held down on iOS. ## 0.19.13 (2021-11-06) ### Bug fixes Fix a bug where backspace, enter, and delete would get applied twice on iOS. ## 0.19.12 (2021-11-04) ### Bug fixes Make sure the workaround for the lost virtual keyboard on Chrome Android also works on slower phones. Slight style change in beforeinput handler Avoid failure cases in viewport-based rendering of very long lines. ## 0.19.11 (2021-11-03) ### Breaking changes `EditorView.scrollPosIntoView` has been deprecated. Use the `EditorView.scrollTo` effect instead. ### New features The new `EditorView.centerOn` effect can be used to scroll a given range to the center of the view. ## 0.19.10 (2021-11-02) ### Bug fixes Don't crash when `IntersectionObserver` fires its callback without any records. Try to handle some backspace issues on Chrome Android Using backspace near uneditable widgets on Chrome Android should now be more reliable. Work around a number of browser bugs by always rendering zero-width spaces around in-content widgets, so that browsers will treat the positions near them as valid cursor positions and not try to run composition across widget boundaries. Work around bogus composition changes created by Chrome Android after handled backspace presses. Work around an issue where tapping on an uneditable node in the editor would sometimes fail to show the virtual keyboard on Chrome Android. Prevent translation services from translating the editor content. Show direction override characters as special chars by default `specialChars` will now, by default, replace direction override chars, to mitigate https://trojansource.codes/ attacks. ### New features The editor view will, if `parent` is given but `root` is not, derive the root from the parent element. Line decorations now accept a `class` property to directly add DOM classes to the line. ## 0.19.9 (2021-10-01) ### Bug fixes Fix an issue where some kinds of reflows in the surrounding document could move unrendered parts of the editor into view without the editor noticing and updating its viewport. Fix an occasional crash in the selection drawing extension. ## 0.19.8 (2021-09-26) ### Bug fixes Fix a bug that could, on DOM changes near block widgets, insert superfluous line breaks. Make interacting with a destroyed editor view do nothing, rather than crash, to avoid tripping people up with pending timeouts and such. Make sure `ViewUpdate.viewportChanged` is true whenever `visibleRanges` changes, so that plugins acting only on visible ranges can use it to check when to update. Fix line-wise cut on empty lines. ## 0.19.7 (2021-09-13) ### Bug fixes The view is now aware of the new `EditorState.readOnly` property, and suppresses events that modify the document when it is true. ## 0.19.6 (2021-09-10) ### Bug fixes Remove a `console.log` that slipped into the previous release. ## 0.19.5 (2021-09-09) ### New features The new `EditorView.scrollTo` effect can be used to scroll a given range into view. ## 0.19.4 (2021-09-01) ### Bug fixes Fix an issue where lines containing just a widget decoration wrapped in a mark decoration could be displayed with 0 height. ## 0.19.3 (2021-08-25) ### Bug fixes Fix a view corruption that could happen in situations involving overlapping mark decorations. ## 0.19.2 (2021-08-23) ### New features The package now exports a `scrollPastEnd` function, which returns an extension that adds space below the document to allow the last line to be scrolled to the top of the editor. ## 0.19.1 (2021-08-11) ### Breaking changes The view now emits new-style user event annotations for the transactions it generates. ### Bug fixes Fix a bug where `coordsAtPos` would allow the passed `side` argument to override widget sides, producing incorrect cursor positions. Fix a bug that could cause content lines to be misaligned in certain situations involving widgets at the end of lines. Fix an issue where, if the browser decided to modify DOM attributes in the content in response to some editing action, the view failed to reset those again. ## 0.18.19 (2021-07-12) ### Bug fixes Fix a regression where `EditorView.editable.of(false)` didn't disable editing on Webkit-based browsers. ## 0.18.18 (2021-07-06) ### Bug fixes Fix a bug that caused `EditorView.moveVertically` to only move by one line, even when given a custom distance, in some cases. Hide Safari's native bold/italic/underline controls for the content. Fix a CSS problem that prevented Safari from breaking words longer than the line in line-wrapping mode. Avoid a problem where composition would be inappropriately abored on Safari. Fix drag-selection that scrolls the content by dragging past the visible viewport. ### New features `posAtCoords` now has an imprecise mode where it'll return an approximate position even for parts of the document that aren't currently rendered. ## 0.18.17 (2021-06-14) ### Bug fixes Make `drawSelection` behave properly when lines are split by block widgets. Make sure drawn selections that span a single line break don't leave a gap between the lines. ## 0.18.16 (2021-06-03) ### Bug fixes Fix a crash that could occur when the document changed during mouse selection. Fix a bug where composition inside styled content would sometimes be inappropriately aborted by editor DOM updates. ### New features `MouseSelectionStyle.update` may now return true to indicate it should be queried for a new selection after the update. ## 0.18.15 (2021-06-01) ### Bug fixes Fix a bug that would, in very specific circumstances, cause `posAtCoords` to go into an infinite loop in Safari. Fix a bug where some types of IME input on Mobile Safari would drop text. ## 0.18.14 (2021-05-28) ### Bug fixes Fix an issue where the DOM selection was sometimes not properly updated when next to a widget. Invert the order in which overlapping decorations are drawn so that higher-precedence decorations are nested inside lower-precedence ones (and thus override their styling). Fix a but in `posAtCoords` where it would in some situations return -1 instead of `null`. ### New features A new plugin field, `PluginField.atomicRanges`, can be used to cause cursor motion to skip past some ranges of the document. ## 0.18.13 (2021-05-20) ### Bug fixes Fix a bug that would cause the content DOM update to crash in specific circumstances. Work around an issue where, after some types of changes, Mobile Safari would ignore Enter presses. Make iOS enter and backspace handling more robust, so that platform bugs are less likely to break those keys in the editor. Fix a regression where Esc + Tab no longer allowed the user to exit the editor. ### New features You can now drop text files into the editor. ## 0.18.12 (2021-05-10) ### Bug fixes Work around a Mobile Safari bug where, after backspacing out the last character on a line, Enter didn't work anymore. Work around a problem in Mobile Safari where you couldn't tap to put the cursor at the end of a line that ended in a widget. ## 0.18.11 (2021-04-30) ### Bug fixes Add an attribute to prevent the Grammarly browser extension from messing with the editor content. Fix more issues around selection handling a Shadow DOM in Safari. ## 0.18.10 (2021-04-27) ### Bug fixes Fix a bug where some types of updates wouldn't properly cause marks around the changes to be joined in the DOM. Fix an issue where the content and gutters in a fixed-height editor could be smaller than the editor height. Fix a crash on Safari when initializing an editor in an unfocused window. Fix a bug where the editor would incorrectly conclude it was out of view in some types of absolutely positioned parent elements. ## 0.18.9 (2021-04-23) ### Bug fixes Fix a crash that occurred when determining DOM coordinates in some specific situations. Fix a crash when a DOM change that ended at a zero-width view element (widget) removed that element from the DOM. Disable autocorrect and autocapitalize by default, since in most code-editor contexts they get in the way. You can use `EditorView.contentAttributes` to override this. Fix a bug that interfered with native touch selection handling on Android. Fix an unnecessary DOM update after composition that would disrupt touch selection on Android. Add a workaround for Safari's broken selection reporting when the editor is in a shadow DOM tree. Fix select-all from the context menu on Safari. ## 0.18.8 (2021-04-19) ### Bug fixes Handle selection replacements where the inserted text matches the start/end of the replaced text better. Fix an issue where the editor would miss scroll events when it was placed in a DOM component slot. ## 0.18.7 (2021-04-13) ### Bug fixes Fix a crash when drag-selecting out of the editor with editable turned off. Backspace and delete now largely work in an editor without a keymap. Pressing backspace on iOS should now properly update the virtual keyboard's capitalize and autocorrect state. Prevent random line-wrapping in (non-wrapping) editors on Mobile Safari. ## 0.18.6 (2021-04-08) ### Bug fixes Fix an issue in the compiled output that would break the code when minified with terser. ## 0.18.5 (2021-04-07) ### Bug fixes Improve handling of bidi text with brackets (conforming to Unicode 13's bidi algorithm). Fix the position where `drawSelection` displays the cursor on bidi boundaries. ## 0.18.4 (2021-04-07) ### Bug fixes Fix an issue where the default focus ring gets obscured by the gutters and active line. Fix an issue where the editor believed Chrome Android didn't support the CSS `tab-size` style. Don't style active lines when there are non-empty selection ranges, so that the active line background doesn't obscure the selection. Make iOS autocapitalize update properly when you press Enter. ## 0.18.3 (2021-03-19) ### Breaking changes The outer DOM element now has class `cm-editor` instead of `cm-wrap` (`cm-wrap` will be present as well until 0.19). ### Bug fixes Improve behavior of `posAtCoords` when the position is near text but not in any character's actual box. ## 0.18.2 (2021-03-19) ### Bug fixes Triple-clicking now selects the line break after the clicked line (if any). Fix an issue where the `drawSelection` plugin would fail to draw the top line of the selection when it started in an empty line. Fix an issue where, at the end of a specific type of composition on iOS, the editor read the DOM before the browser was done updating it. ## 0.18.1 (2021-03-05) ### Bug fixes Fix an issue where, on iOS, some types of IME would cause the composed content to be deleted when confirming a composition. ## 0.18.0 (2021-03-03) ### Breaking changes The `themeClass` function and ``-style selectors in themes are no longer supported (prefixing with `cm-` should be done manually now). Themes must now use `&` (instead of an extra `$`) to target the editor wrapper element. The editor no longer adds `cm-light` or `cm-dark` classes. Targeting light or dark configurations in base themes should now be done by using a `&light` or `&dark` top-level selector. ## 0.17.13 (2021-03-03) ### Bug fixes Work around a Firefox bug where it won't draw the cursor when it is between uneditable elements. Fix a bug that broke built-in mouse event handling. ## 0.17.12 (2021-03-02) ### Bug fixes Avoid interfering with touch events, to allow native selection behavior. Fix a bug that broke sub-selectors with multiple `&` placeholders in themes. ## 0.17.11 (2021-02-25) ### Bug fixes Fix vertical cursor motion on Safari with a larger line-height. Fix incorrect selection drawing (with `drawSelection`) when the selection spans to just after a soft wrap point. Fix an issue where compositions on Safari were inappropriately aborted in some circumstances. The view will now redraw when the `EditorView.phrases` facet changes, to make sure translated text is properly updated. ## 0.17.10 (2021-02-22) ### Bug fixes Long words without spaces, when line-wrapping is enabled, are now properly broken. Fix the horizontal position of selections drawn by `drawSelection` in right-to-left editors with a scrollbar. ## 0.17.9 (2021-02-18) ### Bug fixes Fix an issue where pasting linewise at the start of a line left the cursor before the inserted content. ## 0.17.8 (2021-02-16) ### Bug fixes Fix a problem where the DOM selection and the editor state could get out of sync in non-editable mode. Fix a crash when the editor was hidden on Safari, due to `getClientRects` returning an empty list. Prevent Firefox from making the scrollable element keyboard-focusable. ## 0.17.7 (2021-01-25) ### New features Add an `EditorView.announce` state effect that can be used to conveniently provide screen reader announcements. ## 0.17.6 (2021-01-22) ### Bug fixes Avoid creating very high scroll containers for large documents so that we don't overflow the DOM's fixed-precision numbers. ## 0.17.5 (2021-01-15) ### Bug fixes Fix a bug that would create space-filling placeholders with incorrect height when document is very large. ## 0.17.4 (2021-01-14) ### Bug fixes The `drawSelection` extension will now reuse cursor DOM nodes when the number of cursors stays the same, allowing some degree of cursor transition animation. Makes highlighted special characters styleable (``) and fix their default look in dark themes to have appropriate contrast. ### New features Adds a new `MatchDecorator` helper class which can be used to easily maintain decorations on content that matches a regular expression. ## 0.17.3 (2021-01-06) ### New features The package now also exports a CommonJS module. ## 0.17.2 (2021-01-04) ### Bug fixes Work around Chrome problem where the native shift-enter behavior inserts two line breaks. Make bracket closing and bracket pair removing more reliable on Android. Fix bad cursor position and superfluous change transactions after pressing enter when in a composition on Android. Fix issue where the wrong character was deleted when backspacing out a character before an identical copy of that character on Android. ## 0.17.1 (2020-12-30) ### Bug fixes Fix a bug that prevented `ViewUpdate.focusChanged` from ever being true. ## 0.17.0 (2020-12-29) ### Breaking changes First numbered release. view-6.26.3/LICENSE000066400000000000000000000021361460616253600136020ustar00rootroot00000000000000MIT License Copyright (C) 2018-2021 by Marijn Haverbeke and others Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. view-6.26.3/README.md000066400000000000000000000017541460616253600140610ustar00rootroot00000000000000# @codemirror/view [![NPM version](https://img.shields.io/npm/v/@codemirror/view.svg)](https://www.npmjs.org/package/@codemirror/view) [ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#view) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/next/) | [**CHANGELOG**](https://github.com/codemirror/view/blob/main/CHANGELOG.md) ] This package implements the DOM view component for the [CodeMirror](https://codemirror.net/) code editor. The [project page](https://codemirror.net/) has more information, a number of [examples](https://codemirror.net/examples/) and the [documentation](https://codemirror.net/docs/). This code is released under an [MIT license](https://github.com/codemirror/view/tree/main/LICENSE). We aim to be an inclusive, welcoming community. To make that explicit, we have a [code of conduct](http://contributor-covenant.org/version/1/1/0/) that applies to communication around the project. view-6.26.3/package.json000066400000000000000000000016151460616253600150640ustar00rootroot00000000000000{ "name": "@codemirror/view", "version": "6.26.3", "description": "DOM view component for the CodeMirror code editor", "scripts": { "test": "cm-runtests", "prepare": "cm-buildhelper src/index.ts" }, "keywords": [ "editor", "code" ], "author": { "name": "Marijn Haverbeke", "email": "marijn@haverbeke.berlin", "url": "http://marijnhaverbeke.nl" }, "type": "module", "main": "dist/index.cjs", "exports": { "import": "./dist/index.js", "require": "./dist/index.cjs" }, "types": "dist/index.d.ts", "module": "dist/index.js", "sideEffects": false, "license": "MIT", "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" }, "devDependencies": { "@codemirror/buildhelper": "^1.0.0" }, "repository": { "type": "git", "url": "https://github.com/codemirror/view.git" } } view-6.26.3/src/000077500000000000000000000000001460616253600133625ustar00rootroot00000000000000view-6.26.3/src/README.md000066400000000000000000000047221460616253600146460ustar00rootroot00000000000000The “view” is the part of the editor that the user sees—a DOM component that displays the editor state and allows text input. @EditorViewConfig @EditorView @Direction @BlockInfo @BlockType @BidiSpan @DOMEventHandlers @DOMEventMap @Rect ### Extending the View @Command @ViewPlugin @PluginValue @PluginSpec @ViewUpdate @logException @MouseSelectionStyle @drawSelection @getDrawSelectionConfig @dropCursor @highlightActiveLine @highlightSpecialChars @highlightWhitespace @highlightTrailingWhitespace @placeholder @scrollPastEnd ### Key bindings @KeyBinding @keymap @runScopeHandlers ### Decorations Your code should not try to directly change the DOM structure CodeMirror creates for its content—that will not work. Instead, the way to influence how things are drawn is by providing decorations, which can add styling or replace content with an alternative representation. @Decoration @DecorationSet @WidgetType @MatchDecorator ### Gutters Functionality for showing "gutters" (for line numbers or other purposes) on the side of the editor. See also the [gutter example](../../examples/gutter/). @lineNumbers @highlightActiveLineGutter @gutter @gutters @GutterMarker @gutterLineClass @lineNumberMarkers ### Tooltips Tooltips are DOM elements overlaid on the editor near a given document position. This package helps manage and position such elements. See also the [tooltip example](../../examples/tooltip/). @showTooltip @Tooltip @TooltipView @tooltips @getTooltip @hoverTooltip @hasHoverTooltips @closeHoverTooltips @repositionTooltips ### Panels Panels are UI elements positioned above or below the editor (things like a search dialog). They will take space from the editor when it has a fixed height, and will stay in view even when the editor is partially scrolled out of view. See also the [panel example](../../examples/panel/). @showPanel @PanelConstructor @Panel @getPanel @panels ### Layers Layers are sets of DOM elements drawn over or below the document text. They can be useful for displaying user interface elements that don't take up space and shouldn't influence line wrapping, such as additional cursors. Note that, being outside of the regular DOM order, such elements are invisible to screen readers. Make sure to also [provide](#view.EditorView^announce) any important information they convey in an accessible way. @layer @LayerMarker @RectangleMarker ### Rectangular Selection @rectangularSelection @crosshairCursor view-6.26.3/src/active-line.ts000066400000000000000000000021021460616253600161250ustar00rootroot00000000000000import {Extension} from "@codemirror/state" import {EditorView} from "./editorview" import {ViewPlugin, ViewUpdate} from "./extension" import {Decoration, DecorationSet} from "./decoration" /// Mark lines that have a cursor on them with the `"cm-activeLine"` /// DOM class. export function highlightActiveLine(): Extension { return activeLineHighlighter } const lineDeco = Decoration.line({class: "cm-activeLine"}) const activeLineHighlighter = ViewPlugin.fromClass(class { decorations: DecorationSet constructor(view: EditorView) { this.decorations = this.getDeco(view) } update(update: ViewUpdate) { if (update.docChanged || update.selectionSet) this.decorations = this.getDeco(update.view) } getDeco(view: EditorView) { let lastLineStart = -1, deco = [] for (let r of view.state.selection.ranges) { let line = view.lineBlockAt(r.head) if (line.from > lastLineStart) { deco.push(lineDeco.range(line.from)) lastLineStart = line.from } } return Decoration.set(deco) } }, { decorations: v => v.decorations }) view-6.26.3/src/attributes.ts000066400000000000000000000031251460616253600161210ustar00rootroot00000000000000export type Attrs = {[name: string]: string} export function combineAttrs(source: Attrs, target: Attrs) { for (let name in source) { if (name == "class" && target.class) target.class += " " + source.class else if (name == "style" && target.style) target.style += ";" + source.style else target[name] = source[name] } return target } const noAttrs = Object.create(null) export function attrsEq(a: Attrs | null, b: Attrs | null, ignore?: string): boolean { if (a == b) return true if (!a) a = noAttrs if (!b) b = noAttrs let keysA = Object.keys(a!), keysB = Object.keys(b!) if (keysA.length - (ignore && keysA.indexOf(ignore) > -1 ? 1 : 0) != keysB.length - (ignore && keysB.indexOf(ignore) > -1 ? 1 : 0)) return false for (let key of keysA) { if (key != ignore && (keysB.indexOf(key) == -1 || a![key] !== b![key])) return false } return true } export function updateAttrs(dom: HTMLElement, prev: Attrs | null, attrs: Attrs | null) { let changed = false if (prev) for (let name in prev) if (!(attrs && name in attrs)) { changed = true if (name == "style") dom.style.cssText = "" else dom.removeAttribute(name) } if (attrs) for (let name in attrs) if (!(prev && prev[name] == attrs[name])) { changed = true if (name == "style") dom.style.cssText = attrs[name] else dom.setAttribute(name, attrs[name]) } return changed } export function getAttrs(dom: HTMLElement) { let attrs = Object.create(null) for (let i = 0; i < dom.attributes.length; i++) { let attr = dom.attributes[i] attrs[attr.name] = attr.value } return attrs } view-6.26.3/src/bidi.ts000066400000000000000000000451431460616253600146500ustar00rootroot00000000000000import {EditorSelection, SelectionRange, Line, findClusterBreak} from "@codemirror/state" /// Used to indicate [text direction](#view.EditorView.textDirection). export enum Direction { // (These are chosen to match the base levels, in bidi algorithm // terms, of spans in that direction.) /// Left-to-right. LTR = 0, /// Right-to-left. RTL = 1 } const LTR = Direction.LTR, RTL = Direction.RTL // Codes used for character types: const enum T { L = 1, // Left-to-Right R = 2, // Right-to-Left AL = 4, // Right-to-Left Arabic EN = 8, // European Number AN = 16, // Arabic Number ET = 64, // European Number Terminator CS = 128, // Common Number Separator NI = 256, // Neutral or Isolate (BN, N, WS), NSM = 512, // Non-spacing Mark Strong = T.L | T.R | T.AL, Num = T.EN | T.AN } // Decode a string with each type encoded as log2(type) function dec(str: string): readonly T[] { let result = [] for (let i = 0; i < str.length; i++) result.push(1 << +str[i]) return result } // Character types for codepoints 0 to 0xf8 const LowTypes = dec("88888888888888888888888888888888888666888888787833333333337888888000000000000000000000000008888880000000000000000000000000088888888888888888888888888888888888887866668888088888663380888308888800000000000000000000000800000000000000000000000000000008") // Character types for codepoints 0x600 to 0x6f9 const ArabicTypes = dec("4444448826627288999999999992222222222222222222222222222222222222222222222229999999999999999999994444444444644222822222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222999999949999999229989999223333333333") const Brackets = Object.create(null), BracketStack: number[] = [] // There's a lot more in // https://www.unicode.org/Public/UCD/latest/ucd/BidiBrackets.txt, // which are left out to keep code size down. for (let p of ["()", "[]", "{}"]) { let l = p.charCodeAt(0), r = p.charCodeAt(1) Brackets[l] = r; Brackets[r] = -l } // Tracks direction in and before bracketed ranges. const enum Bracketed { OppositeBefore = 1, EmbedInside = 2, OppositeInside = 4, MaxDepth = 3 * 63 } function charType(ch: number) { return ch <= 0xf7 ? LowTypes[ch] : 0x590 <= ch && ch <= 0x5f4 ? T.R : 0x600 <= ch && ch <= 0x6f9 ? ArabicTypes[ch - 0x600] : 0x6ee <= ch && ch <= 0x8ac ? T.AL : 0x2000 <= ch && ch <= 0x200c ? T.NI : 0xfb50 <= ch && ch <= 0xfdff ? T.AL : T.L } const BidiRE = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac\ufb50-\ufdff]/ /// Represents a contiguous range of text that has a single direction /// (as in left-to-right or right-to-left). export class BidiSpan { /// The direction of this span. get dir(): Direction { return this.level % 2 ? RTL : LTR } /// @internal constructor( /// The start of the span (relative to the start of the line). readonly from: number, /// The end of the span. readonly to: number, /// The ["bidi /// level"](https://unicode.org/reports/tr9/#Basic_Display_Algorithm) /// of the span (in this context, 0 means /// left-to-right, 1 means right-to-left, 2 means left-to-right /// number inside right-to-left text). readonly level: number ) {} /// @internal side(end: boolean, dir: Direction) { return (this.dir == dir) == end ? this.to : this.from } /// @internal forward(forward: boolean, dir: Direction) { return forward == (this.dir == dir) } /// @internal static find(order: readonly BidiSpan[], index: number, level: number, assoc: number) { let maybe = -1 for (let i = 0; i < order.length; i++) { let span = order[i] if (span.from <= index && span.to >= index) { if (span.level == level) return i // When multiple spans match, if assoc != 0, take the one that // covers that side, otherwise take the one with the minimum // level. if (maybe < 0 || (assoc != 0 ? (assoc < 0 ? span.from < index : span.to > index) : order[maybe].level > span.level)) maybe = i } } if (maybe < 0) throw new RangeError("Index out of range") return maybe } } // Arrays of isolates are always sorted by position. Isolates are // never empty. Nested isolates don't stick out of their parent. export type Isolate = {from: number, to: number, direction: Direction, inner: readonly Isolate[]} export function isolatesEq(a: readonly Isolate[], b: readonly Isolate[]) { if (a.length != b.length) return false for (let i = 0; i < a.length; i++) { let iA = a[i], iB = b[i] if (iA.from != iB.from || iA.to != iB.to || iA.direction != iB.direction || !isolatesEq(iA.inner, iB.inner)) return false } return true } // Reused array of character types const types: T[] = [] // Fill in the character types (in `types`) from `from` to `to` and // apply W normalization rules. function computeCharTypes(line: string, rFrom: number, rTo: number, isolates: readonly Isolate[], outerType: T) { for (let iI = 0; iI <= isolates.length; iI++) { let from = iI ? isolates[iI - 1].to : rFrom, to = iI < isolates.length ? isolates[iI].from : rTo let prevType = iI ? T.NI : outerType // W1. Examine each non-spacing mark (NSM) in the level run, and // change the type of the NSM to the type of the previous // character. If the NSM is at the start of the level run, it will // get the type of sor. // W2. Search backwards from each instance of a European number // until the first strong type (R, L, AL, or sor) is found. If an // AL is found, change the type of the European number to Arabic // number. // W3. Change all ALs to R. // (Left after this: L, R, EN, AN, ET, CS, NI) for (let i = from, prev = prevType, prevStrong = prevType; i < to; i++) { let type = charType(line.charCodeAt(i)) if (type == T.NSM) type = prev else if (type == T.EN && prevStrong == T.AL) type = T.AN types[i] = type == T.AL ? T.R : type if (type & T.Strong) prevStrong = type prev = type } // W5. A sequence of European terminators adjacent to European // numbers changes to all European numbers. // W6. Otherwise, separators and terminators change to Other // Neutral. // W7. Search backwards from each instance of a European number // until the first strong type (R, L, or sor) is found. If an L is // found, then change the type of the European number to L. // (Left after this: L, R, EN+AN, NI) for (let i = from, prev = prevType, prevStrong = prevType; i < to; i++) { let type = types[i] if (type == T.CS) { if (i < to - 1 && prev == types[i + 1] && (prev & T.Num)) type = types[i] = prev else types[i] = T.NI } else if (type == T.ET) { let end = i + 1 while (end < to && types[end] == T.ET) end++ let replace = (i && prev == T.EN) || (end < rTo && types[end] == T.EN) ? (prevStrong == T.L ? T.L : T.EN) : T.NI for (let j = i; j < end; j++) types[j] = replace i = end - 1 } else if (type == T.EN && prevStrong == T.L) { types[i] = T.L } prev = type if (type & T.Strong) prevStrong = type } } } // Process brackets throughout a run sequence. function processBracketPairs(line: string, rFrom: number, rTo: number, isolates: readonly Isolate[], outerType: T) { let oppositeType = outerType == T.L ? T.R : T.L for (let iI = 0, sI = 0, context = 0; iI <= isolates.length; iI++) { let from = iI ? isolates[iI - 1].to : rFrom, to = iI < isolates.length ? isolates[iI].from : rTo // N0. Process bracket pairs in an isolating run sequence // sequentially in the logical order of the text positions of the // opening paired brackets using the logic given below. Within this // scope, bidirectional types EN and AN are treated as R. for (let i = from, ch, br, type; i < to; i++) { // Keeps [startIndex, type, strongSeen] triples for each open // bracket on BracketStack. if (br = Brackets[ch = line.charCodeAt(i)]) { if (br < 0) { // Closing bracket for (let sJ = sI - 3; sJ >= 0; sJ -= 3) { if (BracketStack[sJ + 1] == -br) { let flags = BracketStack[sJ + 2] let type = (flags & Bracketed.EmbedInside) ? outerType : !(flags & Bracketed.OppositeInside) ? 0 : (flags & Bracketed.OppositeBefore) ? oppositeType : outerType if (type) types[i] = types[BracketStack[sJ]] = type sI = sJ break } } } else if (BracketStack.length == Bracketed.MaxDepth) { break } else { BracketStack[sI++] = i BracketStack[sI++] = ch BracketStack[sI++] = context } } else if ((type = types[i]) == T.R || type == T.L) { let embed = type == outerType context = embed ? 0 : Bracketed.OppositeBefore for (let sJ = sI - 3; sJ >= 0; sJ -= 3) { let cur = BracketStack[sJ + 2] if (cur & Bracketed.EmbedInside) break if (embed) { BracketStack[sJ + 2] |= Bracketed.EmbedInside } else { if (cur & Bracketed.OppositeInside) break BracketStack[sJ + 2] |= Bracketed.OppositeInside } } } } } } function processNeutrals(rFrom: number, rTo: number, isolates: readonly Isolate[], outerType: T) { for (let iI = 0, prev = outerType; iI <= isolates.length; iI++) { let from = iI ? isolates[iI - 1].to : rFrom, to = iI < isolates.length ? isolates[iI].from : rTo // N1. A sequence of neutrals takes the direction of the // surrounding strong text if the text on both sides has the same // direction. European and Arabic numbers act as if they were R in // terms of their influence on neutrals. Start-of-level-run (sor) // and end-of-level-run (eor) are used at level run boundaries. // N2. Any remaining neutrals take the embedding direction. // (Left after this: L, R, EN+AN) for (let i = from; i < to;) { let type = types[i] if (type == T.NI) { let end = i + 1 for (;;) { if (end == to) { if (iI == isolates.length) break end = isolates[iI++].to to = iI < isolates.length ? isolates[iI].from : rTo } else if (types[end] == T.NI) { end++ } else { break } } let beforeL = prev == T.L let afterL = (end < rTo ? types[end] : outerType) == T.L let replace = beforeL == afterL ? (beforeL ? T.L : T.R) : outerType for (let j = end, jI = iI, fromJ = jI ? isolates[jI - 1].to : rFrom; j > i;) { if (j == fromJ) { j = isolates[--jI].from; fromJ = jI ? isolates[jI - 1].to : rFrom } types[--j] = replace } i = end } else { prev = type i++ } } } } // Find the contiguous ranges of character types in a given range, and // emit spans for them. Flip the order of the spans as appropriate // based on the level, and call through to compute the spans for // isolates at the proper point. function emitSpans(line: string, from: number, to: number, level: number, baseLevel: number, isolates: readonly Isolate[], order: BidiSpan[]) { let ourType = level % 2 ? T.R : T.L if ((level % 2) == (baseLevel % 2)) { // Same dir as base direction, don't flip for (let iCh = from, iI = 0; iCh < to;) { // Scan a section of characters in direction ourType, unless // there's another type of char right after iCh, in which case // we scan a section of other characters (which, if ourType == // T.L, may contain both T.R and T.AN chars). let sameDir = true, isNum = false if (iI == isolates.length || iCh < isolates[iI].from) { let next = types[iCh] if (next != ourType) { sameDir = false; isNum = next == T.AN } } // Holds an array of isolates to pass to a recursive call if we // must recurse (to distinguish T.AN inside an RTL section in // LTR text), null if we can emit directly let recurse: Isolate[] | null = !sameDir && ourType == T.L ? [] : null let localLevel = sameDir ? level : level + 1 let iScan = iCh run: for (;;) { if (iI < isolates.length && iScan == isolates[iI].from) { if (isNum) break run let iso = isolates[iI] // Scan ahead to verify that there is another char in this dir after the isolate(s) if (!sameDir) for (let upto = iso.to, jI = iI + 1;;) { if (upto == to) break run if (jI < isolates.length && isolates[jI].from == upto) upto = isolates[jI++].to else if (types[upto] == ourType) break run else break } iI++ if (recurse) { recurse.push(iso) } else { if (iso.from > iCh) order.push(new BidiSpan(iCh, iso.from, localLevel)) let dirSwap = (iso.direction == LTR) != !(localLevel % 2) computeSectionOrder(line, dirSwap ? level + 1 : level, baseLevel, iso.inner, iso.from, iso.to, order) iCh = iso.to } iScan = iso.to } else if (iScan == to || (sameDir ? types[iScan] != ourType : types[iScan] == ourType)) { break } else { iScan++ } } if (recurse) emitSpans(line, iCh, iScan, level + 1, baseLevel, recurse, order) else if (iCh < iScan) order.push(new BidiSpan(iCh, iScan, localLevel)) iCh = iScan } } else { // Iterate in reverse to flip the span order. Same code again, but // going from the back of the section to the front for (let iCh = to, iI = isolates.length; iCh > from;) { let sameDir = true, isNum = false if (!iI || iCh > isolates[iI - 1].to) { let next = types[iCh - 1] if (next != ourType) { sameDir = false; isNum = next == T.AN } } let recurse: Isolate[] | null = !sameDir && ourType == T.L ? [] : null let localLevel = sameDir ? level : level + 1 let iScan = iCh run: for (;;) { if (iI && iScan == isolates[iI - 1].to) { if (isNum) break run let iso = isolates[--iI] // Scan ahead to verify that there is another char in this dir after the isolate(s) if (!sameDir) for (let upto = iso.from, jI = iI;;) { if (upto == from) break run if (jI && isolates[jI - 1].to == upto) upto = isolates[--jI].from else if (types[upto - 1] == ourType) break run else break } if (recurse) { recurse.push(iso) } else { if (iso.to < iCh) order.push(new BidiSpan(iso.to, iCh, localLevel)) let dirSwap = (iso.direction == LTR) != !(localLevel % 2) computeSectionOrder(line, dirSwap ? level + 1 : level, baseLevel, iso.inner, iso.from, iso.to, order) iCh = iso.from } iScan = iso.from } else if (iScan == from || (sameDir ? types[iScan - 1] != ourType : types[iScan - 1] == ourType)) { break } else { iScan-- } } if (recurse) emitSpans(line, iScan, iCh, level + 1, baseLevel, recurse, order) else if (iScan < iCh) order.push(new BidiSpan(iScan, iCh, localLevel)) iCh = iScan } } } function computeSectionOrder(line: string, level: number, baseLevel: number, isolates: readonly Isolate[], from: number, to: number, order: BidiSpan[]) { let outerType = (level % 2 ? T.R : T.L) as T computeCharTypes(line, from, to, isolates, outerType) processBracketPairs(line, from, to, isolates, outerType) processNeutrals(from, to, isolates, outerType) emitSpans(line, from, to, level, baseLevel, isolates, order) } export function computeOrder(line: string, direction: Direction, isolates: readonly Isolate[]) { if (!line) return [new BidiSpan(0, 0, direction == RTL ? 1 : 0)] if (direction == LTR && !isolates.length && !BidiRE.test(line)) return trivialOrder(line.length) if (isolates.length) while (line.length > types.length) types[types.length] = T.NI // Make sure types array has no gaps let order: BidiSpan[] = [], level = direction == LTR ? 0 : 1 computeSectionOrder(line, level, level, isolates, 0, line.length, order) return order } export function trivialOrder(length: number) { return [new BidiSpan(0, length, 0)] } export let movedOver = "" // This implementation moves strictly visually, without concern for a // traversal visiting every logical position in the string. It will // still do so for simple input, but situations like multiple isolates // with the same level next to each other, or text going against the // main dir at the end of the line, will make some positions // unreachable with this motion. Each visible cursor position will // correspond to the lower-level bidi span that touches it. // // The alternative would be to solve an order globally for a given // line, making sure that it includes every position, but that would // require associating non-canonical (higher bidi span level) // positions with a given visual position, which is likely to confuse // people. (And would generally be a lot more complicated.) export function moveVisually(line: Line, order: readonly BidiSpan[], dir: Direction, start: SelectionRange, forward: boolean) { let startIndex = start.head - line.from let spanI = BidiSpan.find(order, startIndex, start.bidiLevel ?? -1, start.assoc) let span = order[spanI], spanEnd = span.side(forward, dir) // End of span if (startIndex == spanEnd) { let nextI = spanI += forward ? 1 : -1 if (nextI < 0 || nextI >= order.length) return null span = order[spanI = nextI] startIndex = span.side(!forward, dir) spanEnd = span.side(forward, dir) } let nextIndex = findClusterBreak(line.text, startIndex, span.forward(forward, dir)) if (nextIndex < span.from || nextIndex > span.to) nextIndex = spanEnd movedOver = line.text.slice(Math.min(startIndex, nextIndex), Math.max(startIndex, nextIndex)) let nextSpan = spanI == (forward ? order.length - 1 : 0) ? null : order[spanI + (forward ? 1 : -1)] if (nextSpan && nextIndex == spanEnd && nextSpan.level + (forward ? 0 : 1) < span.level) return EditorSelection.cursor(nextSpan.side(!forward, dir) + line.from, nextSpan.forward(forward, dir) ? 1 : -1, nextSpan.level) return EditorSelection.cursor(nextIndex + line.from, span.forward(forward, dir) ? -1 : 1, span.level) } export function autoDirection(text: string, from: number, to: number) { for (let i = from; i < to; i++) { let type = charType(text.charCodeAt(i)) if (type == T.L) return LTR if (type == T.R || type == T.AL) return RTL } return LTR } view-6.26.3/src/blockview.ts000066400000000000000000000203271460616253600157230ustar00rootroot00000000000000import {ContentView, DOMPos, ViewFlag, noChildren, mergeChildrenInto} from "./contentview" import {DocView} from "./docview" import {TextView, MarkView, inlineDOMAtPos, joinInlineInto, coordsInChildren} from "./inlineview" import {clientRectsFor, Rect, clearAttributes} from "./dom" import {LineDecoration, WidgetType, PointDecoration} from "./decoration" import {Attrs, combineAttrs, attrsEq, updateAttrs} from "./attributes" import browser from "./browser" import {EditorView} from "./editorview" import {Text} from "@codemirror/state" export interface BlockView extends ContentView { covers(side: -1 | 1): boolean dom: HTMLElement | null } export class LineView extends ContentView implements BlockView { children: ContentView[] = [] length: number = 0 dom!: HTMLElement | null prevAttrs: Attrs | null | undefined = undefined attrs: Attrs | null = null breakAfter = 0 parent!: DocView | null // Consumes source merge(from: number, to: number, source: BlockView | null, hasStart: boolean, openStart: number, openEnd: number): boolean { if (source) { if (!(source instanceof LineView)) return false if (!this.dom) source.transferDOM(this) // Reuse source.dom when appropriate } if (hasStart) this.setDeco(source ? source.attrs : null) mergeChildrenInto(this, from, to, source ? source.children.slice() : [], openStart, openEnd) return true } split(at: number) { let end = new LineView end.breakAfter = this.breakAfter if (this.length == 0) return end let {i, off} = this.childPos(at) if (off) { end.append(this.children[i].split(off), 0) this.children[i].merge(off, this.children[i].length, null, false, 0, 0) i++ } for (let j = i; j < this.children.length; j++) end.append(this.children[j], 0) while (i > 0 && this.children[i - 1].length == 0) this.children[--i].destroy() this.children.length = i this.markDirty() this.length = at return end } transferDOM(other: LineView) { if (!this.dom) return this.markDirty() other.setDOM(this.dom) other.prevAttrs = this.prevAttrs === undefined ? this.attrs : this.prevAttrs this.prevAttrs = undefined this.dom = null } setDeco(attrs: Attrs | null) { if (!attrsEq(this.attrs, attrs)) { if (this.dom) { this.prevAttrs = this.attrs this.markDirty() } this.attrs = attrs } } append(child: ContentView, openStart: number) { joinInlineInto(this, child, openStart) } // Only called when building a line view in ContentBuilder addLineDeco(deco: LineDecoration) { let attrs = deco.spec.attributes, cls = deco.spec.class if (attrs) this.attrs = combineAttrs(attrs, this.attrs || {}) if (cls) this.attrs = combineAttrs({class: cls}, this.attrs || {}); } domAtPos(pos: number): DOMPos { return inlineDOMAtPos(this, pos) } reuseDOM(node: Node) { if (node.nodeName == "DIV") { this.setDOM(node) this.flags |= ViewFlag.AttrsDirty | ViewFlag.NodeDirty } } sync(view: EditorView, track?: {node: Node, written: boolean}) { if (!this.dom) { this.setDOM(document.createElement("div")) this.dom!.className = "cm-line" this.prevAttrs = this.attrs ? null : undefined } else if (this.flags & ViewFlag.AttrsDirty) { clearAttributes(this.dom) this.dom!.className = "cm-line" this.prevAttrs = this.attrs ? null : undefined } if (this.prevAttrs !== undefined) { updateAttrs(this.dom!, this.prevAttrs, this.attrs) this.dom!.classList.add("cm-line") this.prevAttrs = undefined } super.sync(view, track) let last = this.dom!.lastChild while (last && ContentView.get(last) instanceof MarkView) last = last.lastChild if (!last || !this.length || last.nodeName != "BR" && ContentView.get(last)?.isEditable == false && (!browser.ios || !this.children.some(ch => ch instanceof TextView))) { let hack = document.createElement("BR") ;(hack as any).cmIgnore = true this.dom!.appendChild(hack) } } measureTextSize(): {lineHeight: number, charWidth: number, textHeight: number} | null { if (this.children.length == 0 || this.length > 20) return null let totalWidth = 0, textHeight!: number for (let child of this.children) { if (!(child instanceof TextView) || /[^ -~]/.test(child.text)) return null let rects = clientRectsFor(child.dom!) if (rects.length != 1) return null totalWidth += rects[0].width textHeight = rects[0].height } return !totalWidth ? null : { lineHeight: this.dom!.getBoundingClientRect().height, charWidth: totalWidth / this.length, textHeight } } coordsAt(pos: number, side: number): Rect | null { let rect = coordsInChildren(this, pos, side) // Correct rectangle height for empty lines when the returned // height is larger than the text height. if (!this.children.length && rect && this.parent) { let {heightOracle} = this.parent.view.viewState, height = rect.bottom - rect.top if (Math.abs(height - heightOracle.lineHeight) < 2 && heightOracle.textHeight < height) { let dist = (height - heightOracle.textHeight) / 2 return {top: rect.top + dist, bottom: rect.bottom - dist, left: rect.left, right: rect.left} } } return rect } become(_other: ContentView) { return false } covers() { return true } static find(docView: DocView, pos: number): LineView | null { for (let i = 0, off = 0; i < docView.children.length; i++) { let block = docView.children[i], end = off + block.length if (end >= pos) { if (block instanceof LineView) return block if (end > pos) break } off = end + block.breakAfter } return null } } export class BlockWidgetView extends ContentView implements BlockView { dom!: HTMLElement | null parent!: DocView | null breakAfter = 0 prevWidget: WidgetType | null = null constructor(public widget: WidgetType, public length: number, public deco: PointDecoration) { super() } merge(from: number, to: number, source: ContentView | null, _takeDeco: boolean, openStart: number, openEnd: number): boolean { if (source && (!(source instanceof BlockWidgetView) || !this.widget.compare(source.widget) || from > 0 && openStart <= 0 || to < this.length && openEnd <= 0)) return false this.length = from + (source ? source.length : 0) + (this.length - to) return true } domAtPos(pos: number) { return pos == 0 ? DOMPos.before(this.dom!) : DOMPos.after(this.dom!, pos == this.length) } split(at: number) { let len = this.length - at this.length = at let end = new BlockWidgetView(this.widget, len, this.deco) end.breakAfter = this.breakAfter return end } get children() { return noChildren } sync(view: EditorView) { if (!this.dom || !this.widget.updateDOM(this.dom, view)) { if (this.dom && this.prevWidget) this.prevWidget.destroy(this.dom) this.prevWidget = null this.setDOM(this.widget.toDOM(view)) if (!this.widget.editable) this.dom!.contentEditable = "false" } } get overrideDOMText() { return this.parent ? this.parent!.view.state.doc.slice(this.posAtStart, this.posAtEnd) : Text.empty } domBoundsAround() { return null } become(other: ContentView) { if (other instanceof BlockWidgetView && other.widget.constructor == this.widget.constructor) { if (!other.widget.compare(this.widget)) this.markDirty(true) if (this.dom && !this.prevWidget) this.prevWidget = this.widget this.widget = other.widget this.length = other.length this.deco = other.deco this.breakAfter = other.breakAfter return true } return false } ignoreMutation(): boolean { return true } ignoreEvent(event: Event): boolean { return this.widget.ignoreEvent(event) } get isEditable() { return false } get isWidget() { return true } coordsAt(pos: number, side: number) { return this.widget.coordsAt(this.dom!, pos, side) } destroy() { super.destroy() if (this.dom) this.widget.destroy(this.dom) } covers(side: -1 | 1) { let {startSide, endSide} = this.deco return startSide == endSide ? false : side < 0 ? startSide < 0 : endSide > 0 } } view-6.26.3/src/browser.ts000066400000000000000000000025541460616253600154230ustar00rootroot00000000000000let nav: any = typeof navigator != "undefined" ? navigator : {userAgent: "", vendor: "", platform: ""} let doc: any = typeof document != "undefined" ? document : {documentElement: {style: {}}} const ie_edge = /Edge\/(\d+)/.exec(nav.userAgent) const ie_upto10 = /MSIE \d/.test(nav.userAgent) const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(nav.userAgent) const ie = !!(ie_upto10 || ie_11up || ie_edge) const gecko = !ie && /gecko\/(\d+)/i.test(nav.userAgent) const chrome = !ie && /Chrome\/(\d+)/.exec(nav.userAgent) const webkit = "webkitFontSmoothing" in doc.documentElement.style const safari = !ie && /Apple Computer/.test(nav.vendor) const ios = safari && (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2) export default { mac: ios || /Mac/.test(nav.platform), windows: /Win/.test(nav.platform), linux: /Linux|X11/.test(nav.platform), ie, ie_version: ie_upto10 ? doc.documentMode || 6 : ie_11up ? +ie_11up[1] : ie_edge ? +ie_edge[1] : 0, gecko, gecko_version: gecko ? +(/Firefox\/(\d+)/.exec(nav.userAgent) || [0, 0])[1] : 0, chrome: !!chrome, chrome_version: chrome ? +chrome[1] : 0, ios, android: /Android\b/.test(nav.userAgent), webkit, safari, webkit_version: webkit ? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1] : 0, tabSize: doc.documentElement.style.tabSize != null ? "tab-size" : "-moz-tab-size" } view-6.26.3/src/buildview.ts000066400000000000000000000151571460616253600157350ustar00rootroot00000000000000import {SpanIterator, RangeSet, Text, TextIterator} from "@codemirror/state" import {DecorationSet, Decoration, PointDecoration, LineDecoration, MarkDecoration, WidgetType} from "./decoration" import {ContentView} from "./contentview" import {BlockView, LineView, BlockWidgetView} from "./blockview" import {WidgetView, TextView, MarkView, WidgetBufferView} from "./inlineview" const enum T { Chunk = 512 } const enum Buf { No = 0, Yes = 1, IfCursor = 2 } export class ContentBuilder implements SpanIterator { content: BlockView[] = [] curLine: LineView | null = null breakAtStart = 0 pendingBuffer = Buf.No bufferMarks: readonly MarkDecoration[] = [] // Set to false directly after a widget that covers the position after it atCursorPos = true openStart = -1 openEnd = -1 cursor: TextIterator text: string = "" skip: number textOff: number = 0 constructor(private doc: Text, public pos: number, public end: number, readonly disallowBlockEffectsFor: boolean[]) { this.cursor = doc.iter() this.skip = pos } posCovered() { if (this.content.length == 0) return !this.breakAtStart && this.doc.lineAt(this.pos).from != this.pos let last = this.content[this.content.length - 1] return !(last.breakAfter || last instanceof BlockWidgetView && last.deco.endSide < 0) } getLine() { if (!this.curLine) { this.content.push(this.curLine = new LineView) this.atCursorPos = true } return this.curLine } flushBuffer(active = this.bufferMarks) { if (this.pendingBuffer) { this.curLine!.append(wrapMarks(new WidgetBufferView(-1), active), active.length) this.pendingBuffer = Buf.No } } addBlockWidget(view: BlockWidgetView) { this.flushBuffer() this.curLine = null this.content.push(view) } finish(openEnd: number) { if (this.pendingBuffer && openEnd <= this.bufferMarks.length) this.flushBuffer() else this.pendingBuffer = Buf.No if (!this.posCovered() && !(openEnd && this.content.length && this.content[this.content.length - 1] instanceof BlockWidgetView)) this.getLine() } buildText(length: number, active: readonly MarkDecoration[], openStart: number) { while (length > 0) { if (this.textOff == this.text.length) { let {value, lineBreak, done} = this.cursor.next(this.skip) this.skip = 0 if (done) throw new Error("Ran out of text content when drawing inline views") if (lineBreak) { if (!this.posCovered()) this.getLine() if (this.content.length) this.content[this.content.length - 1].breakAfter = 1 else this.breakAtStart = 1 this.flushBuffer() this.curLine = null this.atCursorPos = true length-- continue } else { this.text = value this.textOff = 0 } } let take = Math.min(this.text.length - this.textOff, length, T.Chunk) this.flushBuffer(active.slice(active.length - openStart)) this.getLine().append(wrapMarks(new TextView(this.text.slice(this.textOff, this.textOff + take)), active), openStart) this.atCursorPos = true this.textOff += take length -= take openStart = 0 } } span(from: number, to: number, active: MarkDecoration[], openStart: number) { this.buildText(to - from, active, openStart) this.pos = to if (this.openStart < 0) this.openStart = openStart } point(from: number, to: number, deco: Decoration, active: MarkDecoration[], openStart: number, index: number) { if (this.disallowBlockEffectsFor[index] && deco instanceof PointDecoration) { if (deco.block) throw new RangeError("Block decorations may not be specified via plugins") if (to > this.doc.lineAt(this.pos).to) throw new RangeError("Decorations that replace line breaks may not be specified via plugins") } let len = to - from if (deco instanceof PointDecoration) { if (deco.block) { if (deco.startSide > 0 && !this.posCovered()) this.getLine() this.addBlockWidget(new BlockWidgetView(deco.widget || NullWidget.block, len, deco)) } else { let view = WidgetView.create(deco.widget || NullWidget.inline, len, len ? 0 : deco.startSide) let cursorBefore = this.atCursorPos && !view.isEditable && openStart <= active.length && (from < to || deco.startSide > 0) let cursorAfter = !view.isEditable && (from < to || openStart > active.length || deco.startSide <= 0) let line = this.getLine() if (this.pendingBuffer == Buf.IfCursor && !cursorBefore && !view.isEditable) this.pendingBuffer = Buf.No this.flushBuffer(active) if (cursorBefore) { line.append(wrapMarks(new WidgetBufferView(1), active), openStart) openStart = active.length + Math.max(0, openStart - active.length) } line.append(wrapMarks(view, active), openStart) this.atCursorPos = cursorAfter this.pendingBuffer = !cursorAfter ? Buf.No : from < to || openStart > active.length ? Buf.Yes : Buf.IfCursor if (this.pendingBuffer) this.bufferMarks = active.slice() } } else if (this.doc.lineAt(this.pos).from == this.pos) { // Line decoration this.getLine().addLineDeco(deco as LineDecoration) } if (len) { // Advance the iterator past the replaced content if (this.textOff + len <= this.text.length) { this.textOff += len } else { this.skip += len - (this.text.length - this.textOff) this.text = "" this.textOff = 0 } this.pos = to } if (this.openStart < 0) this.openStart = openStart } static build(text: Text, from: number, to: number, decorations: readonly DecorationSet[], dynamicDecorationMap: boolean[]): {content: BlockView[], breakAtStart: number, openStart: number, openEnd: number} { let builder = new ContentBuilder(text, from, to, dynamicDecorationMap) builder.openEnd = RangeSet.spans(decorations, from, to, builder) if (builder.openStart < 0) builder.openStart = builder.openEnd builder.finish(builder.openEnd) return builder } } function wrapMarks(view: ContentView, active: readonly MarkDecoration[]) { for (let mark of active) view = new MarkView(mark, [view], view.length) return view } export class NullWidget extends WidgetType { constructor(readonly tag: string) { super() } eq(other: NullWidget) { return other.tag == this.tag } toDOM() { return document.createElement(this.tag) } updateDOM(elt: HTMLElement) { return elt.nodeName.toLowerCase() == this.tag } get isHidden() { return true } static inline = new NullWidget("span") static block = new NullWidget("div") } view-6.26.3/src/contentview.ts000066400000000000000000000302741460616253600163050ustar00rootroot00000000000000import {Text} from "@codemirror/state" import {Rect, maxOffset, domIndex} from "./dom" import {EditorView} from "./editorview" // Track mutated / outdated status of a view node's DOM export const enum ViewFlag { // At least one child is dirty ChildDirty = 1, // The node itself isn't in sync with its child list NodeDirty = 2, // The node's DOM attributes might have changed AttrsDirty = 4, // Mask for all of the dirty flags Dirty = 7, // Set temporarily during a doc view update on the nodes around the // composition Composition = 8, } export class DOMPos { constructor(readonly node: Node, readonly offset: number, readonly precise = true) {} static before(dom: Node, precise?: boolean) { return new DOMPos(dom.parentNode!, domIndex(dom), precise) } static after(dom: Node, precise?: boolean) { return new DOMPos(dom.parentNode!, domIndex(dom) + 1, precise) } } export const noChildren: ContentView[] = [] export abstract class ContentView { parent: ContentView | null = null dom: Node | null = null flags: number = ViewFlag.NodeDirty abstract length: number abstract children: ContentView[] breakAfter!: number get overrideDOMText(): Text | null { return null } get posAtStart(): number { return this.parent ? this.parent.posBefore(this) : 0 } get posAtEnd(): number { return this.posAtStart + this.length } posBefore(view: ContentView): number { let pos = this.posAtStart for (let child of this.children) { if (child == view) return pos pos += child.length + child.breakAfter } throw new RangeError("Invalid child in posBefore") } posAfter(view: ContentView): number { return this.posBefore(view) + view.length } // Will return a rectangle directly before (when side < 0), after // (side > 0) or directly on (when the browser supports it) the // given position. abstract coordsAt(_pos: number, _side: number): Rect | null sync(view: EditorView, track?: {node: Node, written: boolean}) { if (this.flags & ViewFlag.NodeDirty) { let parent = this.dom as HTMLElement let prev: Node | null = null, next for (let child of this.children) { if (child.flags & ViewFlag.Dirty) { if (!child.dom && (next = prev ? prev.nextSibling : parent.firstChild)) { let contentView = ContentView.get(next) if (!contentView || !contentView.parent && contentView.canReuseDOM(child)) child.reuseDOM(next) } child.sync(view, track) child.flags &= ~ViewFlag.Dirty } next = prev ? prev.nextSibling : parent.firstChild if (track && !track.written && track.node == parent && next != child.dom) track.written = true if (child.dom!.parentNode == parent) { while (next && next != child.dom) next = rm(next) } else { parent.insertBefore(child.dom!, next) } prev = child.dom! } next = prev ? prev.nextSibling : parent.firstChild if (next && track && track.node == parent) track.written = true while (next) next = rm(next) } else if (this.flags & ViewFlag.ChildDirty) { for (let child of this.children) if (child.flags & ViewFlag.Dirty) { child.sync(view, track) child.flags &= ~ViewFlag.Dirty } } } reuseDOM(_dom: Node) {} abstract domAtPos(pos: number): DOMPos localPosFromDOM(node: Node, offset: number): number { let after: Node | null if (node == this.dom) { after = this.dom.childNodes[offset] } else { let bias = maxOffset(node) == 0 ? 0 : offset == 0 ? -1 : 1 for (;;) { let parent = node.parentNode! if (parent == this.dom) break if (bias == 0 && parent.firstChild != parent.lastChild) { if (node == parent.firstChild) bias = -1 else bias = 1 } node = parent } if (bias < 0) after = node else after = node.nextSibling } if (after == this.dom!.firstChild) return 0 while (after && !ContentView.get(after)) after = after.nextSibling if (!after) return this.length for (let i = 0, pos = 0;; i++) { let child = this.children[i] if (child.dom == after) return pos pos += child.length + child.breakAfter } } domBoundsAround(from: number, to: number, offset = 0): { startDOM: Node | null, endDOM: Node | null, from: number, to: number } | null { let fromI = -1, fromStart = -1, toI = -1, toEnd = -1 for (let i = 0, pos = offset, prevEnd = offset; i < this.children.length; i++) { let child = this.children[i], end = pos + child.length if (pos < from && end > to) return child.domBoundsAround(from, to, pos) if (end >= from && fromI == -1) { fromI = i fromStart = pos } if (pos > to && child.dom!.parentNode == this.dom) { toI = i toEnd = prevEnd break } prevEnd = end pos = end + child.breakAfter } return {from: fromStart, to: toEnd < 0 ? offset + this.length : toEnd, startDOM: (fromI ? this.children[fromI - 1].dom!.nextSibling : null) || this.dom!.firstChild, endDOM: toI < this.children.length && toI >= 0 ? this.children[toI].dom : null} } markDirty(andParent: boolean = false) { this.flags |= ViewFlag.NodeDirty this.markParentsDirty(andParent) } markParentsDirty(childList: boolean) { for (let parent = this.parent; parent; parent = parent.parent) { if (childList) parent.flags |= ViewFlag.NodeDirty if (parent.flags & ViewFlag.ChildDirty) return parent.flags |= ViewFlag.ChildDirty childList = false } } setParent(parent: ContentView) { if (this.parent != parent) { this.parent = parent if (this.flags & ViewFlag.Dirty) this.markParentsDirty(true) } } setDOM(dom: Node) { if (this.dom == dom) return if (this.dom) (this.dom as any).cmView = null this.dom = dom ;(dom as any).cmView = this } get rootView(): ContentView { for (let v: ContentView = this;;) { let parent = v.parent if (!parent) return v v = parent } } replaceChildren(from: number, to: number, children: ContentView[] = noChildren) { this.markDirty() for (let i = from; i < to; i++) { let child = this.children[i] if (child.parent == this && children.indexOf(child) < 0) child.destroy() } this.children.splice(from, to - from, ...children) for (let i = 0; i < children.length; i++) children[i].setParent(this) } ignoreMutation(_rec: MutationRecord): boolean { return false } ignoreEvent(_event: Event): boolean { return false } childCursor(pos: number = this.length) { return new ChildCursor(this.children, pos, this.children.length) } childPos(pos: number, bias: number = 1): {i: number, off: number} { return this.childCursor().findPos(pos, bias) } toString() { let name = this.constructor.name.replace("View", "") return name + (this.children.length ? "(" + this.children.join() + ")" : this.length ? "[" + (name == "Text" ? (this as any).text : this.length) + "]" : "") + (this.breakAfter ? "#" : "") } static get(node: Node): ContentView | null { return (node as any).cmView } get isEditable() { return true } get isWidget() { return false } get isHidden() { return false } merge(from: number, to: number, source: ContentView | null, hasStart: boolean, openStart: number, openEnd: number): boolean { return false } become(other: ContentView): boolean { return false } canReuseDOM(other: ContentView) { return other.constructor == this.constructor && !((this.flags | other.flags) & ViewFlag.Composition) } abstract split(at: number): ContentView // When this is a zero-length view with a side, this should return a // number <= 0 to indicate it is before its position, or a // number > 0 when after its position. getSide() { return 0 } destroy() { for (let child of this.children) if (child.parent == this) child.destroy() this.parent = null } } ContentView.prototype.breakAfter = 0 // Remove a DOM node and return its next sibling. function rm(dom: Node): Node | null { let next = dom.nextSibling dom.parentNode!.removeChild(dom) return next } export class ChildCursor { off: number = 0 constructor(public children: readonly ContentView[], public pos: number, public i: number) {} findPos(pos: number, bias: number = 1): this { for (;;) { if (pos > this.pos || pos == this.pos && (bias > 0 || this.i == 0 || this.children[this.i - 1].breakAfter)) { this.off = pos - this.pos return this } let next = this.children[--this.i] this.pos -= next.length + next.breakAfter } } } export function replaceRange(parent: ContentView, fromI: number, fromOff: number, toI: number, toOff: number, insert: ContentView[], breakAtStart: number, openStart: number, openEnd: number) { let {children} = parent let before = children.length ? children[fromI] : null let last = insert.length ? insert[insert.length - 1] : null let breakAtEnd = last ? last.breakAfter : breakAtStart // Change within a single child if (fromI == toI && before && !breakAtStart && !breakAtEnd && insert.length < 2 && before.merge(fromOff, toOff, insert.length ? last : null, fromOff == 0, openStart, openEnd)) return if (toI < children.length) { let after = children[toI] // Make sure the end of the child after the update is preserved in `after` if (after && (toOff < after.length || after.breakAfter && last?.breakAfter)) { // If we're splitting a child, separate part of it to avoid that // being mangled when updating the child before the update. if (fromI == toI) { after = after.split(toOff) toOff = 0 } // If the element after the replacement should be merged with // the last replacing element, update `content` if (!breakAtEnd && last && after.merge(0, toOff, last, true, 0, openEnd)) { insert[insert.length - 1] = after } else { // Remove the start of the after element, if necessary, and // add it to `content`. if (toOff || after.children.length && !after.children[0].length) after.merge(0, toOff, null, false, 0, openEnd) insert.push(after) } } else if (after?.breakAfter) { // The element at `toI` is entirely covered by this range. // Preserve its line break, if any. if (last) last.breakAfter = 1 else breakAtStart = 1 } // Since we've handled the next element from the current elements // now, make sure `toI` points after that. toI++ } if (before) { before.breakAfter = breakAtStart if (fromOff > 0) { if (!breakAtStart && insert.length && before.merge(fromOff, before.length, insert[0], false, openStart, 0)) { before.breakAfter = insert.shift()!.breakAfter } else if (fromOff < before.length || before.children.length && before.children[before.children.length - 1].length == 0) { before.merge(fromOff, before.length, null, false, openStart, 0) } fromI++ } } // Try to merge widgets on the boundaries of the replacement while (fromI < toI && insert.length) { if (children[toI - 1].become(insert[insert.length - 1])) { toI-- insert.pop() openEnd = insert.length ? 0 : openStart } else if (children[fromI].become(insert[0])) { fromI++ insert.shift() openStart = insert.length ? 0 : openEnd } else { break } } if (!insert.length && fromI && toI < children.length && !children[fromI - 1].breakAfter && children[toI].merge(0, 0, children[fromI - 1], false, openStart, openEnd)) fromI-- if (fromI < toI || insert.length) parent.replaceChildren(fromI, toI, insert) } export function mergeChildrenInto(parent: ContentView, from: number, to: number, insert: ContentView[], openStart: number, openEnd: number) { let cur = parent.childCursor() let {i: toI, off: toOff} = cur.findPos(to, 1) let {i: fromI, off: fromOff} = cur.findPos(from, -1) let dLen = from - to for (let view of insert) dLen += view.length parent.length += dLen replaceRange(parent, fromI, fromOff, toI, toOff, insert, 0, openStart, openEnd) } view-6.26.3/src/cursor.ts000066400000000000000000000370661460616253600152630ustar00rootroot00000000000000import {EditorState, EditorSelection, SelectionRange, RangeSet, CharCategory, findColumn, findClusterBreak} from "@codemirror/state" import {EditorView} from "./editorview" import {BlockType} from "./decoration" import {LineView} from "./blockview" import {atomicRanges} from "./extension" import {clientRectsFor, textRange, Rect} from "./dom" import {moveVisually, movedOver, Direction} from "./bidi" import {BlockInfo} from "./heightmap" import browser from "./browser" declare global { interface Selection { modify(action: string, direction: string, granularity: string): void } interface Document { caretPositionFromPoint(x: number, y: number): {offsetNode: Node, offset: number} } } export function groupAt(state: EditorState, pos: number, bias: 1 | -1 = 1) { let categorize = state.charCategorizer(pos) let line = state.doc.lineAt(pos), linePos = pos - line.from if (line.length == 0) return EditorSelection.cursor(pos) if (linePos == 0) bias = 1 else if (linePos == line.length) bias = -1 let from = linePos, to = linePos if (bias < 0) from = findClusterBreak(line.text, linePos, false) else to = findClusterBreak(line.text, linePos) let cat = categorize(line.text.slice(from, to)) while (from > 0) { let prev = findClusterBreak(line.text, from, false) if (categorize(line.text.slice(prev, from)) != cat) break from = prev } while (to < line.length) { let next = findClusterBreak(line.text, to) if (categorize(line.text.slice(to, next)) != cat) break to = next } return EditorSelection.range(from + line.from, to + line.from) } // Search the DOM for the {node, offset} position closest to the given // coordinates. Very inefficient and crude, but can usually be avoided // by calling caret(Position|Range)FromPoint instead. function getdx(x: number, rect: ClientRect): number { return rect.left > x ? rect.left - x : Math.max(0, x - rect.right) } function getdy(y: number, rect: ClientRect): number { return rect.top > y ? rect.top - y : Math.max(0, y - rect.bottom) } function yOverlap(a: ClientRect, b: ClientRect): boolean { return a.top < b.bottom - 1 && a.bottom > b.top + 1 } function upTop(rect: ClientRect, top: number): ClientRect { return top < rect.top ? {top, left: rect.left, right: rect.right, bottom: rect.bottom} as ClientRect : rect } function upBot(rect: ClientRect, bottom: number): ClientRect { return bottom > rect.bottom ? {top: rect.top, left: rect.left, right: rect.right, bottom} as ClientRect : rect } function domPosAtCoords(parent: HTMLElement, x: number, y: number): {node: Node, offset: number} { let closest, closestRect!: ClientRect, closestX!: number, closestY!: number, closestOverlap = false let above, below, aboveRect, belowRect for (let child: Node | null = parent.firstChild; child; child = child.nextSibling) { let rects = clientRectsFor(child) for (let i = 0; i < rects.length; i++) { let rect: ClientRect = rects[i] if (closestRect && yOverlap(closestRect, rect)) rect = upTop(upBot(rect, closestRect.bottom), closestRect.top) let dx = getdx(x, rect), dy = getdy(y, rect) if (dx == 0 && dy == 0) return child.nodeType == 3 ? domPosInText(child as Text, x, y) : domPosAtCoords(child as HTMLElement, x, y) if (!closest || closestY > dy || closestY == dy && closestX > dx) { closest = child; closestRect = rect; closestX = dx; closestY = dy let side = dy ? (y < rect.top ? -1 : 1) : dx ? (x < rect.left ? -1 : 1) : 0 closestOverlap = !side || (side > 0 ? i < rects.length - 1 : i > 0) } if (dx == 0) { if (y > rect.bottom && (!aboveRect || aboveRect.bottom < rect.bottom)) { above = child; aboveRect = rect } else if (y < rect.top && (!belowRect || belowRect.top > rect.top)) { below = child; belowRect = rect } } else if (aboveRect && yOverlap(aboveRect, rect)) { aboveRect = upBot(aboveRect, rect.bottom) } else if (belowRect && yOverlap(belowRect, rect)) { belowRect = upTop(belowRect, rect.top) } } } if (aboveRect && aboveRect.bottom >= y) { closest = above; closestRect = aboveRect } else if (belowRect && belowRect.top <= y) { closest = below; closestRect = belowRect } if (!closest) return {node: parent, offset: 0} let clipX = Math.max(closestRect!.left, Math.min(closestRect!.right, x)) if (closest.nodeType == 3) return domPosInText(closest as Text, clipX, y) if (closestOverlap && (closest as HTMLElement).contentEditable != "false") return domPosAtCoords(closest as HTMLElement, clipX, y) let offset = Array.prototype.indexOf.call(parent.childNodes, closest) + (x >= (closestRect!.left + closestRect!.right) / 2 ? 1 : 0) return {node: parent, offset} } function domPosInText(node: Text, x: number, y: number): {node: Node, offset: number} { let len = node.nodeValue!.length let closestOffset = -1, closestDY = 1e9, generalSide = 0 for (let i = 0; i < len; i++) { let rects = textRange(node, i, i + 1).getClientRects() for (let j = 0; j < rects.length; j++) { let rect = rects[j] if (rect.top == rect.bottom) continue if (!generalSide) generalSide = x - rect.left let dy = (rect.top > y ? rect.top - y : y - rect.bottom) - 1 if (rect.left - 1 <= x && rect.right + 1 >= x && dy < closestDY) { let right = x >= (rect.left + rect.right) / 2, after = right if (browser.chrome || browser.gecko) { // Check for RTL on browsers that support getting client // rects for empty ranges. let rectBefore = textRange(node, i).getBoundingClientRect() if (rectBefore.left == rect.right) after = !right } if (dy <= 0) return {node, offset: i + (after ? 1 : 0)} closestOffset = i + (after ? 1 : 0) closestDY = dy } } } return {node, offset: closestOffset > -1 ? closestOffset : generalSide > 0 ? node.nodeValue!.length : 0} } export function posAtCoords(view: EditorView, coords: {x: number, y: number}, precise: boolean, bias: -1 | 1 = -1): number | null { let content = view.contentDOM.getBoundingClientRect(), docTop = content.top + view.viewState.paddingTop let block, {docHeight} = view.viewState let {x, y} = coords, yOffset = y - docTop if (yOffset < 0) return 0 if (yOffset > docHeight) return view.state.doc.length // Scan for a text block near the queried y position for (let halfLine = view.viewState.heightOracle.textHeight / 2, bounced = false;;) { block = view.elementAtHeight(yOffset) if (block.type == BlockType.Text) break for (;;) { // Move the y position out of this block yOffset = bias > 0 ? block.bottom + halfLine : block.top - halfLine if (yOffset >= 0 && yOffset <= docHeight) break // If the document consists entirely of replaced widgets, we // won't find a text block, so return 0 if (bounced) return precise ? null : 0 bounced = true bias = -bias as -1 | 1 } } y = docTop + yOffset let lineStart = block.from // If this is outside of the rendered viewport, we can't determine a position if (lineStart < view.viewport.from) return view.viewport.from == 0 ? 0 : precise ? null : posAtCoordsImprecise(view, content, block, x, y) if (lineStart > view.viewport.to) return view.viewport.to == view.state.doc.length ? view.state.doc.length : precise ? null : posAtCoordsImprecise(view, content, block, x, y) // Prefer ShadowRootOrDocument.elementFromPoint if present, fall back to document if not let doc = view.dom.ownerDocument let root = (view.root as any).elementFromPoint ? view.root as Document : doc let element = root.elementFromPoint(x, y) if (element && !view.contentDOM.contains(element)) element = null // If the element is unexpected, clip x at the sides of the content area and try again if (!element) { x = Math.max(content.left + 1, Math.min(content.right - 1, x)) element = root.elementFromPoint(x, y) if (element && !view.contentDOM.contains(element)) element = null } // There's visible editor content under the point, so we can try // using caret(Position|Range)FromPoint as a shortcut let node: Node | undefined, offset: number = -1 if (element && view.docView.nearest(element)?.isEditable != false) { if (doc.caretPositionFromPoint) { let pos = doc.caretPositionFromPoint(x, y) if (pos) ({offsetNode: node, offset} = pos) } else if (doc.caretRangeFromPoint) { let range = doc.caretRangeFromPoint(x, y) if (range) { ;({startContainer: node, startOffset: offset} = range) if (!view.contentDOM.contains(node) || browser.safari && isSuspiciousSafariCaretResult(node, offset, x) || browser.chrome && isSuspiciousChromeCaretResult(node, offset, x)) node = undefined } } } // No luck, do our own (potentially expensive) search if (!node || !view.docView.dom.contains(node)) { let line = LineView.find(view.docView, lineStart) if (!line) return yOffset > block.top + block.height / 2 ? block.to : block.from ;({node, offset} = domPosAtCoords(line.dom!, x, y)) } let nearest = view.docView.nearest(node) if (!nearest) return null if (nearest.isWidget && nearest.dom?.nodeType == 1) { let rect = (nearest.dom as HTMLElement).getBoundingClientRect() return coords.y < rect.top || coords.y <= rect.bottom && coords.x <= (rect.left + rect.right) / 2 ? nearest.posAtStart : nearest.posAtEnd } else { return nearest.localPosFromDOM(node, offset) + nearest.posAtStart } } function posAtCoordsImprecise(view: EditorView, contentRect: Rect, block: BlockInfo, x: number, y: number) { let into = Math.round((x - contentRect.left) * view.defaultCharacterWidth) if (view.lineWrapping && block.height > view.defaultLineHeight * 1.5) { let textHeight = view.viewState.heightOracle.textHeight let line = Math.floor((y - block.top - (view.defaultLineHeight - textHeight) * 0.5) / textHeight) into += line * view.viewState.heightOracle.lineLength } let content = view.state.sliceDoc(block.from, block.to) return block.from + findColumn(content, into, view.state.tabSize) } // In case of a high line height, Safari's caretRangeFromPoint treats // the space between lines as belonging to the last character of the // line before. This is used to detect such a result so that it can be // ignored (issue #401). function isSuspiciousSafariCaretResult(node: Node, offset: number, x: number) { let len if (node.nodeType != 3 || offset != (len = node.nodeValue!.length)) return false for (let next = node.nextSibling; next; next = next.nextSibling) if (next.nodeType != 1 || next.nodeName != "BR") return false return textRange(node as Text, len - 1, len).getBoundingClientRect().left > x } // Chrome will move positions between lines to the start of the next line function isSuspiciousChromeCaretResult(node: Node, offset: number, x: number) { if (offset != 0) return false for (let cur = node;;) { let parent = cur.parentNode if (!parent || parent.nodeType != 1 || parent.firstChild != cur) return false if ((parent as HTMLElement).classList.contains("cm-line")) break cur = parent } let rect = node.nodeType == 1 ? (node as HTMLElement).getBoundingClientRect() : textRange(node as Text, 0, Math.max(node.nodeValue!.length, 1)).getBoundingClientRect() return x - rect.left > 5 } export function blockAt(view: EditorView, pos: number): BlockInfo { let line = view.lineBlockAt(pos) if (Array.isArray(line.type)) for (let l of line.type) { if (l.to > pos || l.to == pos && (l.to == line.to || l.type == BlockType.Text)) return l } return line } export function moveToLineBoundary(view: EditorView, start: SelectionRange, forward: boolean, includeWrap: boolean) { let line = blockAt(view, start.head) let coords = !includeWrap || line.type != BlockType.Text || !(view.lineWrapping || line.widgetLineBreaks) ? null : view.coordsAtPos(start.assoc < 0 && start.head > line.from ? start.head - 1 : start.head) if (coords) { let editorRect = view.dom.getBoundingClientRect() let direction = view.textDirectionAt(line.from) let pos = view.posAtCoords({x: forward == (direction == Direction.LTR) ? editorRect.right - 1 : editorRect.left + 1, y: (coords.top + coords.bottom) / 2}) if (pos != null) return EditorSelection.cursor(pos, forward ? -1 : 1) } return EditorSelection.cursor(forward ? line.to : line.from, forward ? -1 : 1) } export function moveByChar(view: EditorView, start: SelectionRange, forward: boolean, by?: (initial: string) => (next: string) => boolean) { let line = view.state.doc.lineAt(start.head), spans = view.bidiSpans(line) let direction = view.textDirectionAt(line.from) for (let cur = start, check: null | ((next: string) => boolean) = null;;) { let next = moveVisually(line, spans, direction, cur, forward), char = movedOver if (!next) { if (line.number == (forward ? view.state.doc.lines : 1)) return cur char = "\n" line = view.state.doc.line(line.number + (forward ? 1 : -1)) spans = view.bidiSpans(line) next = view.visualLineSide(line, !forward) } if (!check) { if (!by) return next check = by(char) } else if (!check(char)) { return cur } cur = next } } export function byGroup(view: EditorView, pos: number, start: string) { let categorize = view.state.charCategorizer(pos) let cat = categorize(start) return (next: string) => { let nextCat = categorize(next) if (cat == CharCategory.Space) cat = nextCat return cat == nextCat } } export function moveVertically(view: EditorView, start: SelectionRange, forward: boolean, distance?: number) { let startPos = start.head, dir: -1 | 1 = forward ? 1 : -1 if (startPos == (forward ? view.state.doc.length : 0)) return EditorSelection.cursor(startPos, start.assoc) let goal = start.goalColumn, startY let rect = view.contentDOM.getBoundingClientRect() let startCoords = view.coordsAtPos(startPos, start.assoc || -1), docTop = view.documentTop if (startCoords) { if (goal == null) goal = startCoords.left - rect.left startY = dir < 0 ? startCoords.top : startCoords.bottom } else { let line = view.viewState.lineBlockAt(startPos) if (goal == null) goal = Math.min(rect.right - rect.left, view.defaultCharacterWidth * (startPos - line.from)) startY = (dir < 0 ? line.top : line.bottom) + docTop } let resolvedGoal = rect.left + goal let dist = distance ?? (view.viewState.heightOracle.textHeight >> 1) for (let extra = 0;; extra += 10) { let curY = startY + (dist + extra) * dir let pos = posAtCoords(view, {x: resolvedGoal, y: curY}, false, dir)! if (curY < rect.top || curY > rect.bottom || (dir < 0 ? pos < startPos : pos > startPos)) { let charRect = view.docView.coordsForChar(pos) let assoc = !charRect || curY < charRect.top ? -1 : 1 return EditorSelection.cursor(pos, assoc, undefined, goal) } } } export function skipAtomicRanges(atoms: readonly RangeSet[], pos: number, bias: -1 | 0 | 1) { for (;;) { let moved = 0 for (let set of atoms) { set.between(pos - 1, pos + 1, (from, to, value) => { if (pos > from && pos < to) { let side = moved || bias || (pos - from < to - pos ? -1 : 1) pos = side < 0 ? from : to moved = side } }) } if (!moved) return pos } } export function skipAtoms(view: EditorView, oldPos: SelectionRange, pos: SelectionRange) { let newPos = skipAtomicRanges(view.state.facet(atomicRanges).map(f => f(view)), pos.from, oldPos.head > pos.from ? -1 : 1) return newPos == pos.from ? pos : EditorSelection.cursor(newPos, newPos < pos.from ? 1 : -1) } view-6.26.3/src/decoration.ts000066400000000000000000000354641460616253600160750ustar00rootroot00000000000000import {MapMode, RangeValue, Range, RangeSet} from "@codemirror/state" import {Direction} from "./bidi" import {attrsEq, Attrs} from "./attributes" import {EditorView} from "./editorview" import {Rect} from "./dom" interface MarkDecorationSpec { /// Whether the mark covers its start and end position or not. This /// influences whether content inserted at those positions becomes /// part of the mark. Defaults to false. inclusive?: boolean /// Specify whether the start position of the marked range should be /// inclusive. Overrides `inclusive`, when both are present. inclusiveStart?: boolean /// Whether the end should be inclusive. inclusiveEnd?: boolean /// Add attributes to the DOM elements that hold the text in the /// marked range. attributes?: {[key: string]: string} /// Shorthand for `{attributes: {class: value}}`. class?: string /// Add a wrapping element around the text in the marked range. Note /// that there will not necessarily be a single element covering the /// entire range—other decorations with lower precedence might split /// this one if they partially overlap it, and line breaks always /// end decoration elements. tagName?: string /// When using sets of decorations in /// [`bidiIsolatedRanges`](##view.EditorView^bidiIsolatedRanges), /// this property provides the direction of the isolates. When null /// or not given, it indicates the range has `dir=auto`, and its /// direction should be derived from the first strong directional /// character in it. bidiIsolate?: Direction | null /// Decoration specs allow extra properties, which can be retrieved /// through the decoration's [`spec`](#view.Decoration.spec) /// property. [other: string]: any } interface WidgetDecorationSpec { /// The type of widget to draw here. widget: WidgetType /// Which side of the given position the widget is on. When this is /// positive, the widget will be drawn after the cursor if the /// cursor is on the same position. Otherwise, it'll be drawn before /// it. When multiple widgets sit at the same position, their `side` /// values will determine their ordering—those with a lower value /// come first. Defaults to 0. May not be more than 10000 or less /// than -10000. side?: number /// By default, to avoid unintended mixing of block and inline /// widgets, block widgets with a positive `side` are always drawn /// after all inline widgets at that position, and those with a /// non-positive side before inline widgets. Setting this option to /// `true` for a block widget will turn this off and cause it to be /// rendered between the inline widgets, ordered by `side`. inlineOrder?: boolean /// Determines whether this is a block widgets, which will be drawn /// between lines, or an inline widget (the default) which is drawn /// between the surrounding text. /// /// Note that block-level decorations should not have vertical /// margins, and if you dynamically change their height, you should /// make sure to call /// [`requestMeasure`](#view.EditorView.requestMeasure), so that the /// editor can update its information about its vertical layout. block?: boolean /// Other properties are allowed. [other: string]: any } interface ReplaceDecorationSpec { /// An optional widget to drawn in the place of the replaced /// content. widget?: WidgetType /// Whether this range covers the positions on its sides. This /// influences whether new content becomes part of the range and /// whether the cursor can be drawn on its sides. Defaults to false /// for inline replacements, and true for block replacements. inclusive?: boolean /// Set inclusivity at the start. inclusiveStart?: boolean /// Set inclusivity at the end. inclusiveEnd?: boolean /// Whether this is a block-level decoration. Defaults to false. block?: boolean /// Other properties are allowed. [other: string]: any } interface LineDecorationSpec { /// DOM attributes to add to the element wrapping the line. attributes?: {[key: string]: string} /// Shorthand for `{attributes: {class: value}}`. class?: string /// Other properties are allowed. [other: string]: any } /// Widgets added to the content are described by subclasses of this /// class. Using a description object like that makes it possible to /// delay creating of the DOM structure for a widget until it is /// needed, and to avoid redrawing widgets even if the decorations /// that define them are recreated. export abstract class WidgetType { /// Build the DOM structure for this widget instance. abstract toDOM(view: EditorView): HTMLElement /// Compare this instance to another instance of the same type. /// (TypeScript can't express this, but only instances of the same /// specific class will be passed to this method.) This is used to /// avoid redrawing widgets when they are replaced by a new /// decoration of the same type. The default implementation just /// returns `false`, which will cause new instances of the widget to /// always be redrawn. eq(widget: WidgetType): boolean { return false } /// Update a DOM element created by a widget of the same type (but /// different, non-`eq` content) to reflect this widget. May return /// true to indicate that it could update, false to indicate it /// couldn't (in which case the widget will be redrawn). The default /// implementation just returns false. updateDOM(dom: HTMLElement, view: EditorView): boolean { return false } /// @internal compare(other: WidgetType): boolean { return this == other || this.constructor == other.constructor && this.eq(other) } /// The estimated height this widget will have, to be used when /// estimating the height of content that hasn't been drawn. May /// return -1 to indicate you don't know. The default implementation /// returns -1. get estimatedHeight(): number { return -1 } /// For inline widgets that are displayed inline (as opposed to /// `inline-block`) and introduce line breaks (through `
` tags /// or textual newlines), this must indicate the amount of line /// breaks they introduce. Defaults to 0. get lineBreaks(): number { return 0 } /// Can be used to configure which kinds of events inside the widget /// should be ignored by the editor. The default is to ignore all /// events. ignoreEvent(event: Event): boolean { return true } /// Override the way screen coordinates for positions at/in the /// widget are found. `pos` will be the offset into the widget, and /// `side` the side of the position that is being queried—less than /// zero for before, greater than zero for after, and zero for /// directly at that position. coordsAt(dom: HTMLElement, pos: number, side: number): Rect | null { return null } /// @internal get isHidden() { return false } /// @internal get editable() { return false } /// This is called when the an instance of the widget is removed /// from the editor view. destroy(dom: HTMLElement) {} } /// A decoration set represents a collection of decorated ranges, /// organized for efficient access and mapping. See /// [`RangeSet`](#state.RangeSet) for its methods. export type DecorationSet = RangeSet const enum Side { NonIncEnd = -6e8, // (end of non-inclusive range) GapStart = -5e8, BlockBefore = -4e8, // + widget side option (block widget before) BlockIncStart = -3e8, // (start of inclusive block range) Line = -2e8, // (line widget) InlineBefore = -1e8, // + widget side (inline widget before) InlineIncStart = -1, // (start of inclusive inline range) InlineIncEnd = 1, // (end of inclusive inline range) InlineAfter = 1e8, // + widget side (inline widget after) BlockIncEnd = 2e8, // (end of inclusive block range) BlockAfter = 3e8, // + widget side (block widget after) GapEnd = 4e8, NonIncStart = 5e8 // (start of non-inclusive range) } /// The different types of blocks that can occur in an editor view. export enum BlockType { /// A line of text. Text, /// A block widget associated with the position after it. WidgetBefore, /// A block widget associated with the position before it. WidgetAfter, /// A block widget [replacing](#view.Decoration^replace) a range of content. WidgetRange } /// A decoration provides information on how to draw or style a piece /// of content. You'll usually use it wrapped in a /// [`Range`](#state.Range), which adds a start and end position. /// @nonabstract export abstract class Decoration extends RangeValue { protected constructor( /// @internal readonly startSide: number, /// @internal readonly endSide: number, /// @internal readonly widget: WidgetType | null, /// The config object used to create this decoration. You can /// include additional properties in there to store metadata about /// your decoration. readonly spec: any) { super() } /// @internal point!: boolean /// @internal get heightRelevant() { return false } abstract eq(other: Decoration): boolean /// Create a mark decoration, which influences the styling of the /// content in its range. Nested mark decorations will cause nested /// DOM elements to be created. Nesting order is determined by /// precedence of the [facet](#view.EditorView^decorations), with /// the higher-precedence decorations creating the inner DOM nodes. /// Such elements are split on line boundaries and on the boundaries /// of lower-precedence decorations. static mark(spec: MarkDecorationSpec): Decoration { return new MarkDecoration(spec) } /// Create a widget decoration, which displays a DOM element at the /// given position. static widget(spec: WidgetDecorationSpec): Decoration { let side = Math.max(-10000, Math.min(10000, spec.side || 0)), block = !!spec.block side += (block && !spec.inlineOrder) ? (side > 0 ? Side.BlockAfter : Side.BlockBefore) : (side > 0 ? Side.InlineAfter : Side.InlineBefore) return new PointDecoration(spec, side, side, block, spec.widget || null, false) } /// Create a replace decoration which replaces the given range with /// a widget, or simply hides it. static replace(spec: ReplaceDecorationSpec): Decoration { let block = !!spec.block, startSide, endSide if (spec.isBlockGap) { startSide = Side.GapStart endSide = Side.GapEnd } else { let {start, end} = getInclusive(spec, block) startSide = (start ? (block ? Side.BlockIncStart : Side.InlineIncStart) : Side.NonIncStart) - 1 endSide = (end ? (block ? Side.BlockIncEnd : Side.InlineIncEnd) : Side.NonIncEnd) + 1 } return new PointDecoration(spec, startSide, endSide, block, spec.widget || null, true) } /// Create a line decoration, which can add DOM attributes to the /// line starting at the given position. static line(spec: LineDecorationSpec): Decoration { return new LineDecoration(spec) } /// Build a [`DecorationSet`](#view.DecorationSet) from the given /// decorated range or ranges. If the ranges aren't already sorted, /// pass `true` for `sort` to make the library sort them for you. static set(of: Range | readonly Range[], sort = false): DecorationSet { return RangeSet.of(of, sort) } /// The empty set of decorations. static none = RangeSet.empty as DecorationSet /// @internal hasHeight() { return this.widget ? this.widget.estimatedHeight > -1 : false } } export class MarkDecoration extends Decoration { tagName: string class: string attrs: Attrs | null constructor(spec: MarkDecorationSpec) { let {start, end} = getInclusive(spec) super(start ? Side.InlineIncStart : Side.NonIncStart, end ? Side.InlineIncEnd : Side.NonIncEnd, null, spec) this.tagName = spec.tagName || "span" this.class = spec.class || "" this.attrs = spec.attributes || null } eq(other: Decoration): boolean { return this == other || other instanceof MarkDecoration && this.tagName == other.tagName && (this.class || this.attrs?.class) == (other.class || other.attrs?.class) && attrsEq(this.attrs, other.attrs, "class") } range(from: number, to = from) { if (from >= to) throw new RangeError("Mark decorations may not be empty") return super.range(from, to) } } MarkDecoration.prototype.point = false export class LineDecoration extends Decoration { constructor(spec: LineDecorationSpec) { super(Side.Line, Side.Line, null, spec) } eq(other: Decoration): boolean { return other instanceof LineDecoration && this.spec.class == other.spec.class && attrsEq(this.spec.attributes, other.spec.attributes) } range(from: number, to = from) { if (to != from) throw new RangeError("Line decoration ranges must be zero-length") return super.range(from, to) } } LineDecoration.prototype.mapMode = MapMode.TrackBefore LineDecoration.prototype.point = true export class PointDecoration extends Decoration { constructor(spec: any, startSide: number, endSide: number, public block: boolean, widget: WidgetType | null, readonly isReplace: boolean) { super(startSide, endSide, widget, spec) this.mapMode = !block ? MapMode.TrackDel : startSide <= 0 ? MapMode.TrackBefore : MapMode.TrackAfter } // Only relevant when this.block == true get type() { return this.startSide != this.endSide ? BlockType.WidgetRange : this.startSide <= 0 ? BlockType.WidgetBefore : BlockType.WidgetAfter } get heightRelevant() { return this.block || !!this.widget && (this.widget.estimatedHeight >= 5 || this.widget.lineBreaks > 0) } eq(other: Decoration): boolean { return other instanceof PointDecoration && widgetsEq(this.widget, other.widget) && this.block == other.block && this.startSide == other.startSide && this.endSide == other.endSide } range(from: number, to = from) { if (this.isReplace && (from > to || (from == to && this.startSide > 0 && this.endSide <= 0))) throw new RangeError("Invalid range for replacement decoration") if (!this.isReplace && to != from) throw new RangeError("Widget decorations can only have zero-length ranges") return super.range(from, to) } } PointDecoration.prototype.point = true function getInclusive(spec: { inclusive?: boolean, inclusiveStart?: boolean, inclusiveEnd?: boolean }, block = false): {start: boolean, end: boolean} { let {inclusiveStart: start, inclusiveEnd: end} = spec if (start == null) start = spec.inclusive if (end == null) end = spec.inclusive return {start: start ?? block, end: end ?? block} } function widgetsEq(a: WidgetType | null, b: WidgetType | null): boolean { return a == b || !!(a && b && a.compare(b)) } export function addRange(from: number, to: number, ranges: number[], margin = 0) { let last = ranges.length - 1 if (last >= 0 && ranges[last] + margin >= from) ranges[last] = Math.max(ranges[last], to) else ranges.push(from, to) } view-6.26.3/src/docview.ts000066400000000000000000000710731460616253600154020ustar00rootroot00000000000000import {ChangeSet, RangeSet, findClusterBreak, SelectionRange} from "@codemirror/state" import {ContentView, ChildCursor, ViewFlag, DOMPos, replaceRange} from "./contentview" import {BlockView, LineView, BlockWidgetView} from "./blockview" import {TextView, MarkView} from "./inlineview" import {ContentBuilder} from "./buildview" import browser from "./browser" import {Decoration, DecorationSet, WidgetType, addRange, MarkDecoration} from "./decoration" import {getAttrs} from "./attributes" import {clientRectsFor, isEquivalentPosition, Rect, scrollRectIntoView, getSelection, hasSelection, textRange, DOMSelectionState, textNodeBefore, textNodeAfter} from "./dom" import {ViewUpdate, decorations as decorationsFacet, outerDecorations, ChangedRange, ScrollTarget, scrollHandler, getScrollMargins, logException} from "./extension" import {EditorView} from "./editorview" import {Direction} from "./bidi" type Composition = { range: ChangedRange, text: Text, marks: {node: HTMLElement, deco: MarkDecoration}[], line: HTMLElement } export class DocView extends ContentView { children!: BlockView[] decorations: readonly DecorationSet[] = [] dynamicDecorationMap: boolean[] = [] domChanged: {newSel: SelectionRange | null} | null = null hasComposition: {from: number, to: number} | null = null markedForComposition: Set = new Set lastCompositionAfterCursor = false // Track a minimum width for the editor. When measuring sizes in // measureVisibleLineHeights, this is updated to point at the width // of a given element and its extent in the document. When a change // happens in that range, these are reset. That way, once we've seen // a line/element of a given length, we keep the editor wide enough // to fit at least that element, until it is changed, at which point // we forget it again. minWidth = 0 minWidthFrom = 0 minWidthTo = 0 // Track whether the DOM selection was set in a lossy way, so that // we don't mess it up when reading it back it impreciseAnchor: DOMPos | null = null impreciseHead: DOMPos | null = null forceSelection = false dom!: HTMLElement // Used by the resize observer to ignore resizes that we caused // ourselves lastUpdate = Date.now() get length() { return this.view.state.doc.length } constructor(readonly view: EditorView) { super() this.setDOM(view.contentDOM) this.children = [new LineView] this.children[0].setParent(this) this.updateDeco() this.updateInner([new ChangedRange(0, 0, 0, view.state.doc.length)], 0, null) } // Update the document view to a given state. update(update: ViewUpdate) { let changedRanges = update.changedRanges if (this.minWidth > 0 && changedRanges.length) { if (!changedRanges.every(({fromA, toA}) => toA < this.minWidthFrom || fromA > this.minWidthTo)) { this.minWidth = this.minWidthFrom = this.minWidthTo = 0 } else { this.minWidthFrom = update.changes.mapPos(this.minWidthFrom, 1) this.minWidthTo = update.changes.mapPos(this.minWidthTo, 1) } } let readCompositionAt = -1 if (this.view.inputState.composing >= 0) { if (this.domChanged?.newSel) readCompositionAt = this.domChanged.newSel.head else if (!touchesComposition(update.changes, this.hasComposition) && !update.selectionSet) readCompositionAt = update.state.selection.main.head } let composition = readCompositionAt > -1 ? findCompositionRange(this.view, update.changes, readCompositionAt) : null this.domChanged = null if (this.hasComposition) { this.markedForComposition.clear() let {from, to} = this.hasComposition changedRanges = new ChangedRange(from, to, update.changes.mapPos(from, -1), update.changes.mapPos(to, 1)) .addToSet(changedRanges.slice()) } this.hasComposition = composition ? {from: composition.range.fromB, to: composition.range.toB} : null // When the DOM nodes around the selection are moved to another // parent, Chrome sometimes reports a different selection through // getSelection than the one that it actually shows to the user. // This forces a selection update when lines are joined to work // around that. Issue #54 if ((browser.ie || browser.chrome) && !composition && update && update.state.doc.lines != update.startState.doc.lines) this.forceSelection = true let prevDeco = this.decorations, deco = this.updateDeco() let decoDiff = findChangedDeco(prevDeco, deco, update.changes) changedRanges = ChangedRange.extendWithRanges(changedRanges, decoDiff) if (!(this.flags & ViewFlag.Dirty) && changedRanges.length == 0) { return false } else { this.updateInner(changedRanges, update.startState.doc.length, composition) if (update.transactions.length) this.lastUpdate = Date.now() return true } } // Used by update and the constructor do perform the actual DOM // update private updateInner(changes: readonly ChangedRange[], oldLength: number, composition: Composition | null) { this.view.viewState.mustMeasureContent = true this.updateChildren(changes, oldLength, composition) let {observer} = this.view observer.ignore(() => { // Lock the height during redrawing, since Chrome sometimes // messes with the scroll position during DOM mutation (though // no relayout is triggered and I cannot imagine how it can // recompute the scroll position without a layout) this.dom.style.height = this.view.viewState.contentHeight / this.view.scaleY + "px" this.dom.style.flexBasis = this.minWidth ? this.minWidth + "px" : "" // Chrome will sometimes, when DOM mutations occur directly // around the selection, get confused and report a different // selection from the one it displays (issue #218). This tries // to detect that situation. let track = browser.chrome || browser.ios ? {node: observer.selectionRange.focusNode!, written: false} : undefined this.sync(this.view, track) this.flags &= ~ViewFlag.Dirty if (track && (track.written || observer.selectionRange.focusNode != track.node)) this.forceSelection = true this.dom.style.height = "" }) this.markedForComposition.forEach(cView => cView.flags &= ~ViewFlag.Composition) let gaps = [] if (this.view.viewport.from || this.view.viewport.to < this.view.state.doc.length) for (let child of this.children) if (child instanceof BlockWidgetView && child.widget instanceof BlockGapWidget) gaps.push(child.dom!) observer.updateGaps(gaps) } private updateChildren(changes: readonly ChangedRange[], oldLength: number, composition: Composition | null) { let ranges = composition ? composition.range.addToSet(changes.slice()) : changes let cursor = this.childCursor(oldLength) for (let i = ranges.length - 1;; i--) { let next = i >= 0 ? ranges[i] : null if (!next) break let {fromA, toA, fromB, toB} = next, content, breakAtStart, openStart, openEnd if (composition && composition.range.fromB < toB && composition.range.toB > fromB) { let before = ContentBuilder.build(this.view.state.doc, fromB, composition.range.fromB, this.decorations, this.dynamicDecorationMap) let after = ContentBuilder.build(this.view.state.doc, composition.range.toB, toB, this.decorations, this.dynamicDecorationMap) breakAtStart = before.breakAtStart openStart = before.openStart; openEnd = after.openEnd let compLine = this.compositionView(composition) if (after.breakAtStart) { compLine.breakAfter = 1 } else if (after.content.length && compLine.merge(compLine.length, compLine.length, after.content[0], false, after.openStart, 0)) { compLine.breakAfter = after.content[0].breakAfter after.content.shift() } if (before.content.length && compLine.merge(0, 0, before.content[before.content.length - 1], true, 0, before.openEnd)) { before.content.pop() } content = before.content.concat(compLine).concat(after.content) } else { ;({content, breakAtStart, openStart, openEnd} = ContentBuilder.build(this.view.state.doc, fromB, toB, this.decorations, this.dynamicDecorationMap)) } let {i: toI, off: toOff} = cursor.findPos(toA, 1) let {i: fromI, off: fromOff} = cursor.findPos(fromA, -1) replaceRange(this, fromI, fromOff, toI, toOff, content, breakAtStart, openStart, openEnd) } if (composition) this.fixCompositionDOM(composition) } private compositionView(composition: Composition) { let cur: ContentView = new TextView(composition.text.nodeValue!) cur.flags |= ViewFlag.Composition for (let {deco} of composition.marks) cur = new MarkView(deco, [cur], cur.length) let line = new LineView line.append(cur, 0) return line } private fixCompositionDOM(composition: Composition) { let fix = (dom: Node, cView: ContentView) => { cView.flags |= ViewFlag.Composition | (cView.children.some(c => c.flags & ViewFlag.Dirty) ? ViewFlag.ChildDirty : 0) this.markedForComposition.add(cView) let prev = ContentView.get(dom) if (prev && prev != cView) prev.dom = null cView.setDOM(dom) } let pos = this.childPos(composition.range.fromB, 1) let cView: ContentView = this.children[pos.i] fix(composition.line, cView) for (let i = composition.marks.length - 1; i >= -1; i--) { pos = cView.childPos(pos.off, 1) cView = cView.children[pos.i] fix(i >= 0 ? composition.marks[i].node : composition.text, cView) } } // Sync the DOM selection to this.state.selection updateSelection(mustRead = false, fromPointer = false) { if (mustRead || !this.view.observer.selectionRange.focusNode) this.view.observer.readSelectionRange() let activeElt = this.view.root.activeElement, focused = activeElt == this.dom let selectionNotFocus = !focused && hasSelection(this.dom, this.view.observer.selectionRange) && !(activeElt && this.dom.contains(activeElt)) if (!(focused || fromPointer || selectionNotFocus)) return let force = this.forceSelection this.forceSelection = false let main = this.view.state.selection.main let anchor = this.moveToLine(this.domAtPos(main.anchor)) let head = main.empty ? anchor : this.moveToLine(this.domAtPos(main.head)) // Always reset on Firefox when next to an uneditable node to // avoid invisible cursor bugs (#111) if (browser.gecko && main.empty && !this.hasComposition && betweenUneditable(anchor)) { let dummy = document.createTextNode("") this.view.observer.ignore(() => anchor.node.insertBefore(dummy, anchor.node.childNodes[anchor.offset] || null)) anchor = head = new DOMPos(dummy, 0) force = true } let domSel = this.view.observer.selectionRange // If the selection is already here, or in an equivalent position, don't touch it if (force || !domSel.focusNode || ( !isEquivalentPosition(anchor.node, anchor.offset, domSel.anchorNode, domSel.anchorOffset) || !isEquivalentPosition(head.node, head.offset, domSel.focusNode, domSel.focusOffset) ) && !this.suppressWidgetCursorChange(domSel, main)) { this.view.observer.ignore(() => { // Chrome Android will hide the virtual keyboard when tapping // inside an uneditable node, and not bring it back when we // move the cursor to its proper position. This tries to // restore the keyboard by cycling focus. if (browser.android && browser.chrome && this.dom.contains(domSel.focusNode) && inUneditable(domSel.focusNode, this.dom)) { this.dom.blur() this.dom.focus({preventScroll: true}) } let rawSel = getSelection(this.view.root) if (!rawSel) { // No DOM selection for some reason—do nothing } else if (main.empty) { // Work around https://bugzilla.mozilla.org/show_bug.cgi?id=1612076 if (browser.gecko) { let nextTo = nextToUneditable(anchor.node, anchor.offset) if (nextTo && nextTo != (NextTo.Before | NextTo.After)) { let text = (nextTo == NextTo.Before ? textNodeBefore : textNodeAfter)(anchor.node, anchor.offset) if (text) anchor = new DOMPos(text.node, text.offset) } } rawSel.collapse(anchor.node, anchor.offset) if (main.bidiLevel != null && (rawSel as any).caretBidiLevel !== undefined) (rawSel as any).caretBidiLevel = main.bidiLevel } else if (rawSel.extend) { // Selection.extend can be used to create an 'inverted' selection // (one where the focus is before the anchor), but not all // browsers support it yet. rawSel.collapse(anchor.node, anchor.offset) // Safari will ignore the call above when the editor is // hidden, and then raise an error on the call to extend // (#940). try { rawSel.extend(head.node, head.offset) } catch(_) {} } else { // Primitive (IE) way let range = document.createRange() if (main.anchor > main.head) [anchor, head] = [head, anchor] range.setEnd(head.node, head.offset) range.setStart(anchor.node, anchor.offset) rawSel.removeAllRanges() rawSel.addRange(range) } if (selectionNotFocus && this.view.root.activeElement == this.dom) { this.dom.blur() if (activeElt) (activeElt as HTMLElement).focus() } }) this.view.observer.setSelectionRange(anchor, head) } this.impreciseAnchor = anchor.precise ? null : new DOMPos(domSel.anchorNode!, domSel.anchorOffset) this.impreciseHead = head.precise ? null: new DOMPos(domSel.focusNode!, domSel.focusOffset) } // If a zero-length widget is inserted next to the cursor during // composition, avoid moving it across it and disrupting the // composition. suppressWidgetCursorChange(sel: DOMSelectionState, cursor: SelectionRange) { return this.hasComposition && cursor.empty && isEquivalentPosition(sel.focusNode!, sel.focusOffset, sel.anchorNode, sel.anchorOffset) && this.posFromDOM(sel.focusNode!, sel.focusOffset) == cursor.head } enforceCursorAssoc() { if (this.hasComposition) return let {view} = this, cursor = view.state.selection.main let sel = getSelection(view.root) let {anchorNode, anchorOffset} = view.observer.selectionRange if (!sel || !cursor.empty || !cursor.assoc || !sel.modify) return let line = LineView.find(this, cursor.head) if (!line) return let lineStart = line.posAtStart if (cursor.head == lineStart || cursor.head == lineStart + line.length) return let before = this.coordsAt(cursor.head, -1), after = this.coordsAt(cursor.head, 1) if (!before || !after || before.bottom > after.top) return let dom = this.domAtPos(cursor.head + cursor.assoc) sel.collapse(dom.node, dom.offset) sel.modify("move", cursor.assoc < 0 ? "forward" : "backward", "lineboundary") // This can go wrong in corner cases like single-character lines, // so check and reset if necessary. view.observer.readSelectionRange() let newRange = view.observer.selectionRange if (view.docView.posFromDOM(newRange.anchorNode!, newRange.anchorOffset) != cursor.from) sel.collapse(anchorNode, anchorOffset) } // If a position is in/near a block widget, move it to a nearby text // line, since we don't want the cursor inside a block widget. moveToLine(pos: DOMPos) { // Block widgets will return positions before/after them, which // are thus directly in the document DOM element. let dom = this.dom!, newPos if (pos.node != dom) return pos for (let i = pos.offset; !newPos && i < dom.childNodes.length; i++) { let view = ContentView.get(dom.childNodes[i]) if (view instanceof LineView) newPos = view.domAtPos(0) } for (let i = pos.offset - 1; !newPos && i >= 0; i--) { let view = ContentView.get(dom.childNodes[i]) if (view instanceof LineView) newPos = view.domAtPos(view.length) } return newPos ? new DOMPos(newPos.node, newPos.offset, true) : pos } nearest(dom: Node): ContentView | null { for (let cur: Node | null = dom; cur;) { let domView = ContentView.get(cur) if (domView && domView.rootView == this) return domView cur = cur.parentNode } return null } posFromDOM(node: Node, offset: number): number { let view = this.nearest(node) if (!view) throw new RangeError("Trying to find position for a DOM position outside of the document") return view.localPosFromDOM(node, offset) + view.posAtStart } domAtPos(pos: number): DOMPos { let {i, off} = this.childCursor().findPos(pos, -1) for (; i < this.children.length - 1;) { let child = this.children[i] if (off < child.length || child instanceof LineView) break i++; off = 0 } return this.children[i].domAtPos(off) } coordsAt(pos: number, side: number): Rect | null { let best = null, bestPos = 0 for (let off = this.length, i = this.children.length - 1; i >= 0; i--) { let child = this.children[i], end = off - child.breakAfter, start = end - child.length if (end < pos) break if (start <= pos && (start < pos || child.covers(-1)) && (end > pos || child.covers(1)) && (!best || child instanceof LineView && !(best instanceof LineView && side >= 0))) { best = child bestPos = start } off = start } return best ? best.coordsAt(pos - bestPos, side) : null } coordsForChar(pos: number) { let {i, off} = this.childPos(pos, 1), child: ContentView = this.children[i] if (!(child instanceof LineView)) return null while (child.children.length) { let {i, off: childOff} = child.childPos(off, 1) for (;; i++) { if (i == child.children.length) return null if ((child = child.children[i]).length) break } off = childOff } if (!(child instanceof TextView)) return null let end = findClusterBreak(child.text, off) if (end == off) return null let rects = textRange(child.dom as Text, off, end).getClientRects() for (let i = 0; i < rects.length; i++) { let rect = rects[i] if (i == rects.length - 1 || rect.top < rect.bottom && rect.left < rect.right) return rect } return null } measureVisibleLineHeights(viewport: {from: number, to: number}) { let result = [], {from, to} = viewport let contentWidth = this.view.contentDOM.clientWidth let isWider = contentWidth > Math.max(this.view.scrollDOM.clientWidth, this.minWidth) + 1 let widest = -1, ltr = this.view.textDirection == Direction.LTR for (let pos = 0, i = 0; i < this.children.length; i++) { let child = this.children[i], end = pos + child.length if (end > to) break if (pos >= from) { let childRect = child.dom!.getBoundingClientRect() result.push(childRect.height) if (isWider) { let last = child.dom!.lastChild let rects = last ? clientRectsFor(last) : [] if (rects.length) { let rect = rects[rects.length - 1] let width = ltr ? rect.right - childRect.left : childRect.right - rect.left if (width > widest) { widest = width this.minWidth = contentWidth this.minWidthFrom = pos this.minWidthTo = end } } } } pos = end + child.breakAfter } return result } textDirectionAt(pos: number) { let {i} = this.childPos(pos, 1) return getComputedStyle(this.children[i].dom!).direction == "rtl" ? Direction.RTL : Direction.LTR } measureTextSize(): {lineHeight: number, charWidth: number, textHeight: number} { for (let child of this.children) { if (child instanceof LineView) { let measure = child.measureTextSize() if (measure) return measure } } // If no workable line exists, force a layout of a measurable element let dummy = document.createElement("div"), lineHeight!: number, charWidth!: number, textHeight!: number dummy.className = "cm-line" dummy.style.width = "99999px" dummy.style.position = "absolute" dummy.textContent = "abc def ghi jkl mno pqr stu" this.view.observer.ignore(() => { this.dom.appendChild(dummy) let rect = clientRectsFor(dummy.firstChild!)[0] lineHeight = dummy.getBoundingClientRect().height charWidth = rect ? rect.width / 27 : 7 textHeight = rect ? rect.height : lineHeight dummy.remove() }) return {lineHeight, charWidth, textHeight} } childCursor(pos: number = this.length): ChildCursor { // Move back to start of last element when possible, so that // `ChildCursor.findPos` doesn't have to deal with the edge case // of being after the last element. let i = this.children.length if (i) pos -= this.children[--i].length return new ChildCursor(this.children, pos, i) } computeBlockGapDeco(): DecorationSet { let deco = [], vs = this.view.viewState for (let pos = 0, i = 0;; i++) { let next = i == vs.viewports.length ? null : vs.viewports[i] let end = next ? next.from - 1 : this.length if (end > pos) { let height = (vs.lineBlockAt(end).bottom - vs.lineBlockAt(pos).top) / this.view.scaleY deco.push(Decoration.replace({ widget: new BlockGapWidget(height), block: true, inclusive: true, isBlockGap: true, }).range(pos, end)) } if (!next) break pos = next.to + 1 } return Decoration.set(deco) } updateDeco() { let i = 0 let allDeco = this.view.state.facet(decorationsFacet).map(d => { let dynamic = this.dynamicDecorationMap[i++] = typeof d == "function" return dynamic ? (d as (view: EditorView) => DecorationSet)(this.view) : d as DecorationSet }) let dynamicOuter = false, outerDeco = this.view.state.facet(outerDecorations).map((d, i) => { let dynamic = typeof d == "function" if (dynamic) dynamicOuter = true return dynamic ? (d as (view: EditorView) => DecorationSet)(this.view) : d as DecorationSet }) if (outerDeco.length) { this.dynamicDecorationMap[i++] = dynamicOuter allDeco.push(RangeSet.join(outerDeco)) } this.decorations = [ ...allDeco, this.computeBlockGapDeco(), this.view.viewState.lineGapDeco ] while (i < this.decorations.length) this.dynamicDecorationMap[i++] = false return this.decorations } scrollIntoView(target: ScrollTarget) { if (target.isSnapshot) { let ref = this.view.viewState.lineBlockAt(target.range.head) this.view.scrollDOM.scrollTop = ref.top - target.yMargin this.view.scrollDOM.scrollLeft = target.xMargin return } for (let handler of this.view.state.facet(scrollHandler)) { try { if (handler(this.view, target.range, target)) return true } catch(e) { logException(this.view.state, e, "scroll handler") } } let {range} = target let rect = this.coordsAt(range.head, range.empty ? range.assoc : range.head > range.anchor ? -1 : 1), other if (!rect) return if (!range.empty && (other = this.coordsAt(range.anchor, range.anchor > range.head ? -1 : 1))) rect = {left: Math.min(rect.left, other.left), top: Math.min(rect.top, other.top), right: Math.max(rect.right, other.right), bottom: Math.max(rect.bottom, other.bottom)} let margins = getScrollMargins(this.view) let targetRect = { left: rect.left - margins.left, top: rect.top - margins.top, right: rect.right + margins.right, bottom: rect.bottom + margins.bottom } let {offsetWidth, offsetHeight} = this.view.scrollDOM scrollRectIntoView(this.view.scrollDOM, targetRect, range.head < range.anchor ? -1 : 1, target.x, target.y, Math.max(Math.min(target.xMargin, offsetWidth), -offsetWidth), Math.max(Math.min(target.yMargin, offsetHeight), -offsetHeight), this.view.textDirection == Direction.LTR) } // Will never be called but needs to be present split!: () => ContentView } function betweenUneditable(pos: DOMPos) { return pos.node.nodeType == 1 && pos.node.firstChild && (pos.offset == 0 || (pos.node.childNodes[pos.offset - 1] as HTMLElement).contentEditable == "false") && (pos.offset == pos.node.childNodes.length || (pos.node.childNodes[pos.offset] as HTMLElement).contentEditable == "false") } class BlockGapWidget extends WidgetType { constructor(readonly height: number) { super() } toDOM() { let elt = document.createElement("div") elt.className = "cm-gap" this.updateDOM(elt) return elt } eq(other: BlockGapWidget) { return other.height == this.height } updateDOM(elt: HTMLElement) { elt.style.height = this.height + "px" return true } get editable() { return true } get estimatedHeight() { return this.height } ignoreEvent() { return false } } export function findCompositionNode(view: EditorView, headPos: number): {from: number, to: number, node: Text} | null { let sel = view.observer.selectionRange if (!sel.focusNode) return null let textBefore = textNodeBefore(sel.focusNode, sel.focusOffset) let textAfter = textNodeAfter(sel.focusNode, sel.focusOffset) let textNode = textBefore || textAfter if (textAfter && textBefore && textAfter.node != textBefore.node) { let descAfter = ContentView.get(textAfter.node) if (!descAfter || descAfter instanceof TextView && descAfter.text != textAfter.node.nodeValue) { textNode = textAfter } else if (view.docView.lastCompositionAfterCursor) { let descBefore = ContentView.get(textBefore.node) if (!(!descBefore || descBefore instanceof TextView && descBefore.text != textBefore.node.nodeValue)) textNode = textAfter } } view.docView.lastCompositionAfterCursor = textNode != textBefore if (!textNode) return null let from = headPos - textNode.offset return {from, to: from + textNode.node.nodeValue!.length, node: textNode.node} } function findCompositionRange(view: EditorView, changes: ChangeSet, headPos: number): Composition | null { let found = findCompositionNode(view, headPos) if (!found) return null let {node: textNode, from, to} = found, text = textNode.nodeValue! // Don't try to preserve multi-line compositions if (/[\n\r]/.test(text)) return null if (view.state.doc.sliceString(found.from, found.to) != text) return null let inv = changes.invertedDesc let range = new ChangedRange(inv.mapPos(from), inv.mapPos(to), from, to) let marks: {node: HTMLElement, deco: MarkDecoration}[] = [] for (let parent = textNode.parentNode as HTMLElement;; parent = parent.parentNode as HTMLElement) { let parentView = ContentView.get(parent) if (parentView instanceof MarkView) marks.push({node: parent, deco: parentView.mark}) else if (parentView instanceof LineView || parent.nodeName == "DIV" && parent.parentNode == view.contentDOM) return {range, text: textNode, marks, line: parent as HTMLElement} else if (parent != view.contentDOM) marks.push({node: parent, deco: new MarkDecoration({ inclusive: true, attributes: getAttrs(parent), tagName: parent.tagName.toLowerCase() })}) else return null } } const enum NextTo { Before = 1, After = 2 } function nextToUneditable(node: Node, offset: number) { if (node.nodeType != 1) return 0 return (offset && (node.childNodes[offset - 1] as any).contentEditable == "false" ? NextTo.Before : 0) | (offset < node.childNodes.length && (node.childNodes[offset] as any).contentEditable == "false" ? NextTo.After : 0) } class DecorationComparator { changes: number[] = [] compareRange(from: number, to: number) { addRange(from, to, this.changes) } comparePoint(from: number, to: number) { addRange(from, to, this.changes) } } function findChangedDeco(a: readonly DecorationSet[], b: readonly DecorationSet[], diff: ChangeSet) { let comp = new DecorationComparator RangeSet.compare(a, b, diff, comp) return comp.changes } function inUneditable(node: Node | null, inside: HTMLElement) { for (let cur = node; cur && cur != inside; cur = (cur as HTMLElement).assignedSlot || cur.parentNode) { if (cur.nodeType == 1 && (cur as HTMLElement).contentEditable == 'false') { return true; } } return false; } function touchesComposition(changes: ChangeSet, composition: null | {from: number, to: number}) { let touched = false if (composition) changes.iterChangedRanges((from, to) => { if (from < composition!.to && to > composition!.from) touched = true }) return touched } view-6.26.3/src/dom.ts000066400000000000000000000333671460616253600145250ustar00rootroot00000000000000export function getSelection(root: DocumentOrShadowRoot): Selection | null { let target // Browsers differ on whether shadow roots have a getSelection // method. If it exists, use that, otherwise, call it on the // document. if ((root as any).nodeType == 11) { // Shadow root target = (root as any).getSelection ? root as Document : (root as ShadowRoot).ownerDocument } else { target = root as Document } return target.getSelection() } export function contains(dom: Node, node: Node | null) { return node ? dom == node || dom.contains(node.nodeType != 1 ? node.parentNode : node) : false } export function deepActiveElement(doc: Document) { let elt = doc.activeElement while (elt && elt.shadowRoot) elt = elt.shadowRoot.activeElement return elt } export function hasSelection(dom: HTMLElement, selection: SelectionRange): boolean { if (!selection.anchorNode) return false try { // Firefox will raise 'permission denied' errors when accessing // properties of `sel.anchorNode` when it's in a generated CSS // element. return contains(dom, selection.anchorNode) } catch(_) { return false } } export function clientRectsFor(dom: Node) { if (dom.nodeType == 3) return textRange(dom as Text, 0, dom.nodeValue!.length).getClientRects() else if (dom.nodeType == 1) return (dom as HTMLElement).getClientRects() else return [] as any as DOMRectList } // Scans forward and backward through DOM positions equivalent to the // given one to see if the two are in the same place (i.e. after a // text node vs at the end of that text node) export function isEquivalentPosition(node: Node, off: number, targetNode: Node | null, targetOff: number): boolean { return targetNode ? (scanFor(node, off, targetNode, targetOff, -1) || scanFor(node, off, targetNode, targetOff, 1)) : false } export function domIndex(node: Node): number { for (var index = 0;; index++) { node = node.previousSibling! if (!node) return index } } export function isBlockElement(node: Node): boolean { return node.nodeType == 1 && /^(DIV|P|LI|UL|OL|BLOCKQUOTE|DD|DT|H\d|SECTION|PRE)$/.test(node.nodeName) } function scanFor(node: Node, off: number, targetNode: Node, targetOff: number, dir: -1 | 1): boolean { for (;;) { if (node == targetNode && off == targetOff) return true if (off == (dir < 0 ? 0 : maxOffset(node))) { if (node.nodeName == "DIV") return false let parent = node.parentNode if (!parent || parent.nodeType != 1) return false off = domIndex(node) + (dir < 0 ? 0 : 1) node = parent } else if (node.nodeType == 1) { node = node.childNodes[off + (dir < 0 ? -1 : 0)] if (node.nodeType == 1 && (node as HTMLElement).contentEditable == "false") return false off = dir < 0 ? maxOffset(node) : 0 } else { return false } } } export function maxOffset(node: Node): number { return node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length } /// Basic rectangle type. export interface Rect { readonly left: number readonly right: number readonly top: number readonly bottom: number } export function flattenRect(rect: Rect, left: boolean) { let x = left ? rect.left : rect.right return {left: x, right: x, top: rect.top, bottom: rect.bottom} } function windowRect(win: Window): Rect { let vp = win.visualViewport if (vp) return { left: 0, right: vp.width, top: 0, bottom: vp.height } return {left: 0, right: win.innerWidth, top: 0, bottom: win.innerHeight} } export type ScrollStrategy = "nearest" | "start" | "end" | "center" export function getScale(elt: HTMLElement, rect: DOMRect) { let scaleX = rect.width / elt.offsetWidth let scaleY = rect.height / elt.offsetHeight if (scaleX > 0.995 && scaleX < 1.005 || !isFinite(scaleX) || Math.abs(rect.width - elt.offsetWidth) < 1) scaleX = 1 if (scaleY > 0.995 && scaleY < 1.005 || !isFinite(scaleY) || Math.abs(rect.height - elt.offsetHeight) < 1) scaleY = 1 return {scaleX, scaleY} } export function scrollRectIntoView(dom: HTMLElement, rect: Rect, side: -1 | 1, x: ScrollStrategy, y: ScrollStrategy, xMargin: number, yMargin: number, ltr: boolean) { let doc = dom.ownerDocument!, win = doc.defaultView || window for (let cur: any = dom, stop = false; cur && !stop;) { if (cur.nodeType == 1) { // Element let bounding: Rect, top = cur == doc.body let scaleX = 1, scaleY = 1 if (top) { bounding = windowRect(win) } else { if (/^(fixed|sticky)$/.test(getComputedStyle(cur).position)) stop = true if (cur.scrollHeight <= cur.clientHeight && cur.scrollWidth <= cur.clientWidth) { cur = cur.assignedSlot || cur.parentNode continue } let rect = cur.getBoundingClientRect() ;({scaleX, scaleY} = getScale(cur, rect)) // Make sure scrollbar width isn't included in the rectangle bounding = {left: rect.left, right: rect.left + cur.clientWidth * scaleX, top: rect.top, bottom: rect.top + cur.clientHeight * scaleY} } let moveX = 0, moveY = 0 if (y == "nearest") { if (rect.top < bounding.top) { moveY = -(bounding.top - rect.top + yMargin) if (side > 0 && rect.bottom > bounding.bottom + moveY) moveY = rect.bottom - bounding.bottom + moveY + yMargin } else if (rect.bottom > bounding.bottom) { moveY = rect.bottom - bounding.bottom + yMargin if (side < 0 && (rect.top - moveY) < bounding.top) moveY = -(bounding.top + moveY - rect.top + yMargin) } } else { let rectHeight = rect.bottom - rect.top, boundingHeight = bounding.bottom - bounding.top let targetTop = y == "center" && rectHeight <= boundingHeight ? rect.top + rectHeight / 2 - boundingHeight / 2 : y == "start" || y == "center" && side < 0 ? rect.top - yMargin : rect.bottom - boundingHeight + yMargin moveY = targetTop - bounding.top } if (x == "nearest") { if (rect.left < bounding.left) { moveX = -(bounding.left - rect.left + xMargin) if (side > 0 && rect.right > bounding.right + moveX) moveX = rect.right - bounding.right + moveX + xMargin } else if (rect.right > bounding.right) { moveX = rect.right - bounding.right + xMargin if (side < 0 && rect.left < bounding.left + moveX) moveX = -(bounding.left + moveX - rect.left + xMargin) } } else { let targetLeft = x == "center" ? rect.left + (rect.right - rect.left) / 2 - (bounding.right - bounding.left) / 2 : (x == "start") == ltr ? rect.left - xMargin : rect.right - (bounding.right - bounding.left) + xMargin moveX = targetLeft - bounding.left } if (moveX || moveY) { if (top) { win.scrollBy(moveX, moveY) } else { let movedX = 0, movedY = 0 if (moveY) { let start = cur.scrollTop cur.scrollTop += moveY / scaleY movedY = (cur.scrollTop - start) * scaleY } if (moveX) { let start = cur.scrollLeft cur.scrollLeft += moveX / scaleX movedX = (cur.scrollLeft - start) * scaleX } rect = {left: rect.left - movedX, top: rect.top - movedY, right: rect.right - movedX, bottom: rect.bottom - movedY} as ClientRect if (movedX && Math.abs(movedX - moveX) < 1) x = "nearest" if (movedY && Math.abs(movedY - moveY) < 1) y = "nearest" } } if (top) break cur = cur.assignedSlot || cur.parentNode } else if (cur.nodeType == 11) { // A shadow root cur = cur.host } else { break } } } export function scrollableParent(dom: HTMLElement) { let doc = dom.ownerDocument for (let cur = dom.parentNode as HTMLElement | null; cur;) { if (cur == doc.body) { break } else if (cur.nodeType == 1) { if (cur.scrollHeight > cur.clientHeight || cur.scrollWidth > cur.clientWidth) return cur cur = cur.assignedSlot || cur.parentNode as HTMLElement | null } else if (cur.nodeType == 11) { cur = (cur as any).host } else { break } } return null } export interface SelectionRange { focusNode: Node | null, focusOffset: number, anchorNode: Node | null, anchorOffset: number } export class DOMSelectionState implements SelectionRange { anchorNode: Node | null = null anchorOffset: number = 0 focusNode: Node | null = null focusOffset: number = 0 eq(domSel: SelectionRange): boolean { return this.anchorNode == domSel.anchorNode && this.anchorOffset == domSel.anchorOffset && this.focusNode == domSel.focusNode && this.focusOffset == domSel.focusOffset } setRange(range: SelectionRange) { let {anchorNode, focusNode} = range // Clip offsets to node size to avoid crashes when Safari reports bogus offsets (#1152) this.set(anchorNode, Math.min(range.anchorOffset, anchorNode ? maxOffset(anchorNode) : 0), focusNode, Math.min(range.focusOffset, focusNode ? maxOffset(focusNode) : 0)) } set(anchorNode: Node | null, anchorOffset: number, focusNode: Node | null, focusOffset: number) { this.anchorNode = anchorNode; this.anchorOffset = anchorOffset this.focusNode = focusNode; this.focusOffset = focusOffset } } let preventScrollSupported: null | false | {preventScroll: boolean} = null // Feature-detects support for .focus({preventScroll: true}), and uses // a fallback kludge when not supported. export function focusPreventScroll(dom: HTMLElement) { if ((dom as any).setActive) return (dom as any).setActive() // in IE if (preventScrollSupported) return dom.focus(preventScrollSupported) let stack = [] for (let cur: Node | null = dom; cur; cur = cur.parentNode) { stack.push(cur, (cur as any).scrollTop, (cur as any).scrollLeft) if (cur == cur.ownerDocument) break } dom.focus(preventScrollSupported == null ? { get preventScroll() { preventScrollSupported = {preventScroll: true} return true } } : undefined) if (!preventScrollSupported) { preventScrollSupported = false for (let i = 0; i < stack.length;) { let elt = stack[i++] as HTMLElement, top = stack[i++] as number, left = stack[i++] as number if (elt.scrollTop != top) elt.scrollTop = top if (elt.scrollLeft != left) elt.scrollLeft = left } } } let scratchRange: Range | null export function textRange(node: Text, from: number, to = from) { let range = scratchRange || (scratchRange = document.createRange()) range.setEnd(node, to) range.setStart(node, from) return range } export function dispatchKey(elt: HTMLElement, name: string, code: number, mods?: KeyboardEvent): boolean { let options: KeyboardEventInit = {key: name, code: name, keyCode: code, which: code, cancelable: true} if (mods) ({altKey: options.altKey, ctrlKey: options.ctrlKey, shiftKey: options.shiftKey, metaKey: options.metaKey} = mods) let down = new KeyboardEvent("keydown", options) ;(down as any).synthetic = true elt.dispatchEvent(down) let up = new KeyboardEvent("keyup", options) ;(up as any).synthetic = true elt.dispatchEvent(up) return down.defaultPrevented || up.defaultPrevented } export function getRoot(node: Node | null | undefined): DocumentOrShadowRoot | null { while (node) { if (node && (node.nodeType == 9 || node.nodeType == 11 && (node as ShadowRoot).host)) return node as unknown as DocumentOrShadowRoot node = (node as HTMLElement).assignedSlot || node.parentNode } return null } export function clearAttributes(node: HTMLElement) { while (node.attributes.length) node.removeAttributeNode(node.attributes[0]) } export function atElementStart(doc: HTMLElement, selection: SelectionRange) { let node = selection.focusNode, offset = selection.focusOffset if (!node || selection.anchorNode != node || selection.anchorOffset != offset) return false // Safari can report bogus offsets (#1152) offset = Math.min(offset, maxOffset(node)) for (;;) { if (offset) { if (node.nodeType != 1) return false let prev: Node = node.childNodes[offset - 1] if ((prev as HTMLElement).contentEditable == "false") offset-- else { node = prev; offset = maxOffset(node) } } else if (node == doc) { return true } else { offset = domIndex(node) node = node.parentNode! } } } export function isScrolledToBottom(elt: HTMLElement) { return elt.scrollTop > Math.max(1, elt.scrollHeight - elt.clientHeight - 4) } export function textNodeBefore(startNode: Node, startOffset: number): {node: Text, offset: number} | null { for (let node = startNode, offset = startOffset;;) { if (node.nodeType == 3 && offset > 0) { return {node: node as Text, offset: offset} } else if (node.nodeType == 1 && offset > 0) { if ((node as HTMLElement).contentEditable == "false") return null node = node.childNodes[offset - 1] offset = maxOffset(node) } else if (node.parentNode && !isBlockElement(node)) { offset = domIndex(node) node = node.parentNode } else { return null } } } export function textNodeAfter(startNode: Node, startOffset: number): {node: Text, offset: number} | null { for (let node = startNode, offset = startOffset;;) { if (node.nodeType == 3 && offset < node.nodeValue!.length) { return {node: node as Text, offset: offset} } else if (node.nodeType == 1 && offset < node.childNodes.length) { if ((node as HTMLElement).contentEditable == "false") return null node = node.childNodes[offset] offset = 0 } else if (node.parentNode && !isBlockElement(node)) { offset = domIndex(node) + 1 node = node.parentNode } else { return null } } } view-6.26.3/src/domchange.ts000066400000000000000000000314261460616253600156650ustar00rootroot00000000000000import {EditorView} from "./editorview" import {inputHandler, editable} from "./extension" import {contains, dispatchKey} from "./dom" import browser from "./browser" import {DOMReader, DOMPoint, LineBreakPlaceholder} from "./domreader" import {findCompositionNode} from "./docview" import {EditorSelection, Text, Transaction, TransactionSpec} from "@codemirror/state" export class DOMChange { bounds: { startDOM: Node | null, endDOM: Node | null, from: number, to: number } | null = null text: string = "" newSel: EditorSelection | null constructor(view: EditorView, start: number, end: number, readonly typeOver: boolean) { let {impreciseHead: iHead, impreciseAnchor: iAnchor} = view.docView if (view.state.readOnly && start > -1) { // Ignore changes when the editor is read-only this.newSel = null } else if (start > -1 && (this.bounds = view.docView.domBoundsAround(start, end, 0))) { let selPoints = iHead || iAnchor ? [] : selectionPoints(view) let reader = new DOMReader(selPoints, view.state) reader.readRange(this.bounds.startDOM, this.bounds.endDOM) this.text = reader.text this.newSel = selectionFromPoints(selPoints, this.bounds.from) } else { let domSel = view.observer.selectionRange let head = iHead && iHead.node == domSel.focusNode && iHead.offset == domSel.focusOffset || !contains(view.contentDOM, domSel.focusNode) ? view.state.selection.main.head : view.docView.posFromDOM(domSel.focusNode!, domSel.focusOffset) let anchor = iAnchor && iAnchor.node == domSel.anchorNode && iAnchor.offset == domSel.anchorOffset || !contains(view.contentDOM, domSel.anchorNode) ? view.state.selection.main.anchor : view.docView.posFromDOM(domSel.anchorNode!, domSel.anchorOffset) // iOS will refuse to select the block gaps when doing // select-all. // Chrome will put the selection *inside* them, confusing // posFromDOM let vp = view.viewport if ((browser.ios || browser.chrome) && view.state.selection.main.empty && head != anchor && (vp.from > 0 || vp.to < view.state.doc.length)) { let from = Math.min(head, anchor), to = Math.max(head, anchor) let offFrom = vp.from - from, offTo = vp.to - to if ((offFrom == 0 || offFrom == 1 || from == 0) && (offTo == 0 || offTo == -1 || to == view.state.doc.length)) { head = 0 anchor = view.state.doc.length } } this.newSel = EditorSelection.single(anchor, head) } } } export function applyDOMChange(view: EditorView, domChange: DOMChange): boolean { let change: undefined | {from: number, to: number, insert: Text} let {newSel} = domChange, sel = view.state.selection.main let lastKey = view.inputState.lastKeyTime > Date.now() - 100 ? view.inputState.lastKeyCode : -1 if (domChange.bounds) { let {from, to} = domChange.bounds let preferredPos = sel.from, preferredSide = null // Prefer anchoring to end when Backspace is pressed (or, on // Android, when something was deleted) if (lastKey === 8 || browser.android && domChange.text.length < to - from) { preferredPos = sel.to preferredSide = "end" } let diff = findDiff(view.state.doc.sliceString(from, to, LineBreakPlaceholder), domChange.text, preferredPos - from, preferredSide) if (diff) { // Chrome inserts two newlines when pressing shift-enter at the // end of a line. DomChange drops one of those. if (browser.chrome && lastKey == 13 && diff.toB == diff.from + 2 && domChange.text.slice(diff.from, diff.toB) == LineBreakPlaceholder + LineBreakPlaceholder) diff.toB-- change = {from: from + diff.from, to: from + diff.toA, insert: Text.of(domChange.text.slice(diff.from, diff.toB).split(LineBreakPlaceholder))} } } else if (newSel && (!view.hasFocus && view.state.facet(editable) || newSel.main.eq(sel))) { newSel = null } if (!change && !newSel) return false if (!change && domChange.typeOver && !sel.empty && newSel && newSel.main.empty) { // Heuristic to notice typing over a selected character change = {from: sel.from, to: sel.to, insert: view.state.doc.slice(sel.from, sel.to)} } else if (change && change.from >= sel.from && change.to <= sel.to && (change.from != sel.from || change.to != sel.to) && (sel.to - sel.from) - (change.to - change.from) <= 4) { // If the change is inside the selection and covers most of it, // assume it is a selection replace (with identical characters at // the start/end not included in the diff) change = { from: sel.from, to: sel.to, insert: view.state.doc.slice(sel.from, change.from).append(change.insert).append(view.state.doc.slice(change.to, sel.to)) } } else if ((browser.mac || browser.android) && change && change.from == change.to && change.from == sel.head - 1 && /^\. ?$/.test(change.insert.toString()) && view.contentDOM.getAttribute("autocorrect") == "off") { // Detect insert-period-on-double-space Mac and Android behavior, // and transform it into a regular space insert. if (newSel && change.insert.length == 2) newSel = EditorSelection.single(newSel.main.anchor - 1, newSel.main.head - 1) change = {from: sel.from, to: sel.to, insert: Text.of([" "])} } else if (browser.chrome && change && change.from == change.to && change.from == sel.head && change.insert.toString() == "\n " && view.lineWrapping) { // In Chrome, if you insert a space at the start of a wrapped // line, it will actually insert a newline and a space, causing a // bogus new line to be created in CodeMirror (#968) if (newSel) newSel = EditorSelection.single(newSel.main.anchor - 1, newSel.main.head - 1) change = {from: sel.from, to: sel.to, insert: Text.of([" "])} } if (change) { if (browser.ios && view.inputState.flushIOSKey(change)) return true // Android browsers don't fire reasonable key events for enter, // backspace, or delete. So this detects changes that look like // they're caused by those keys, and reinterprets them as key // events. (Some of these keys are also handled by beforeinput // events and the pendingAndroidKey mechanism, but that's not // reliable in all situations.) if (browser.android && ((change.to == sel.to && // GBoard will sometimes remove a space it just inserted // after a completion when you press enter (change.from == sel.from || change.from == sel.from - 1 && view.state.sliceDoc(change.from, sel.from) == " ") && change.insert.length == 1 && change.insert.lines == 2 && dispatchKey(view.contentDOM, "Enter", 13)) || ((change.from == sel.from - 1 && change.to == sel.to && change.insert.length == 0 || lastKey == 8 && change.insert.length < change.to - change.from && change.to > sel.head) && dispatchKey(view.contentDOM, "Backspace", 8)) || (change.from == sel.from && change.to == sel.to + 1 && change.insert.length == 0 && dispatchKey(view.contentDOM, "Delete", 46)))) return true let text = change.insert.toString() if (view.inputState.composing >= 0) view.inputState.composing++ let defaultTr: Transaction | null let defaultInsert = () => defaultTr || (defaultTr = applyDefaultInsert(view, change!, newSel)) if (!view.state.facet(inputHandler).some(h => h(view, change!.from, change!.to, text, defaultInsert))) view.dispatch(defaultInsert()) return true } else if (newSel && !newSel.main.eq(sel)) { let scrollIntoView = false, userEvent = "select" if (view.inputState.lastSelectionTime > Date.now() - 50) { if (view.inputState.lastSelectionOrigin == "select") scrollIntoView = true userEvent = view.inputState.lastSelectionOrigin! } view.dispatch({selection: newSel, scrollIntoView, userEvent}) return true } else { return false } } function applyDefaultInsert(view: EditorView, change: {from: number, to: number, insert: Text}, newSel: EditorSelection | null): Transaction { let tr: TransactionSpec, startState = view.state, sel = startState.selection.main if (change.from >= sel.from && change.to <= sel.to && change.to - change.from >= (sel.to - sel.from) / 3 && (!newSel || newSel.main.empty && newSel.main.from == change.from + change.insert.length) && view.inputState.composing < 0) { let before = sel.from < change.from ? startState.sliceDoc(sel.from, change.from) : "" let after = sel.to > change.to ? startState.sliceDoc(change.to, sel.to) : "" tr = startState.replaceSelection(view.state.toText( before + change.insert.sliceString(0, undefined, view.state.lineBreak) + after)) } else { let changes = startState.changes(change) let mainSel = newSel && newSel.main.to <= changes.newLength ? newSel.main : undefined // Try to apply a composition change to all cursors if (startState.selection.ranges.length > 1 && view.inputState.composing >= 0 && change.to <= sel.to && change.to >= sel.to - 10) { let replaced = view.state.sliceDoc(change.from, change.to) let compositionRange: {from: number, to: number}, composition = newSel && findCompositionNode(view, newSel.main.head) if (composition) { let dLen = change.insert.length - (change.to - change.from) compositionRange = {from: composition.from, to: composition.to - dLen} } else { compositionRange = view.state.doc.lineAt(sel.head) } let offset = sel.to - change.to, size = sel.to - sel.from tr = startState.changeByRange(range => { if (range.from == sel.from && range.to == sel.to) return {changes, range: mainSel || range.map(changes)} let to = range.to - offset, from = to - replaced.length if (range.to - range.from != size || view.state.sliceDoc(from, to) != replaced || // Unfortunately, there's no way to make multiple // changes in the same node work without aborting // composition, so cursors in the composition range are // ignored. range.to >= compositionRange.from && range.from <= compositionRange.to) return {range} let rangeChanges = startState.changes({from, to, insert: change!.insert}), selOff = range.to - sel.to return { changes: rangeChanges, range: !mainSel ? range.map(rangeChanges) : EditorSelection.range(Math.max(0, mainSel.anchor + selOff), Math.max(0, mainSel.head + selOff)) } }) } else { tr = { changes, selection: mainSel && startState.selection.replaceRange(mainSel) } } } let userEvent = "input.type" if (view.composing || view.inputState.compositionPendingChange && view.inputState.compositionEndedAt > Date.now() - 50) { view.inputState.compositionPendingChange = false userEvent += ".compose" if (view.inputState.compositionFirstChange) { userEvent += ".start" view.inputState.compositionFirstChange = false } } return startState.update(tr, {userEvent, scrollIntoView: true}) } function findDiff(a: string, b: string, preferredPos: number, preferredSide: string | null) : {from: number, toA: number, toB: number} | null { let minLen = Math.min(a.length, b.length) let from = 0 while (from < minLen && a.charCodeAt(from) == b.charCodeAt(from)) from++ if (from == minLen && a.length == b.length) return null let toA = a.length, toB = b.length while (toA > 0 && toB > 0 && a.charCodeAt(toA - 1) == b.charCodeAt(toB - 1)) { toA--; toB-- } if (preferredSide == "end") { let adjust = Math.max(0, from - Math.min(toA, toB)) preferredPos -= toA + adjust - from } if (toA < from && a.length < b.length) { let move = preferredPos <= from && preferredPos >= toA ? from - preferredPos : 0 from -= move toB = from + (toB - toA) toA = from } else if (toB < from) { let move = preferredPos <= from && preferredPos >= toB ? from - preferredPos : 0 from -= move toA = from + (toA - toB) toB = from } return {from, toA, toB} } function selectionPoints(view: EditorView) { let result: DOMPoint[] = [] if (view.root.activeElement != view.contentDOM) return result let {anchorNode, anchorOffset, focusNode, focusOffset} = view.observer.selectionRange if (anchorNode) { result.push(new DOMPoint(anchorNode, anchorOffset)) if (focusNode != anchorNode || focusOffset != anchorOffset) result.push(new DOMPoint(focusNode!, focusOffset)) } return result } function selectionFromPoints(points: DOMPoint[], base: number): EditorSelection | null { if (points.length == 0) return null let anchor = points[0].pos, head = points.length == 2 ? points[1].pos : anchor return anchor > -1 && head > -1 ? EditorSelection.single(anchor + base, head + base) : null } view-6.26.3/src/domobserver.ts000066400000000000000000000432271460616253600162710ustar00rootroot00000000000000import browser from "./browser" import {ContentView, ViewFlag} from "./contentview" import {EditorView} from "./editorview" import {editable} from "./extension" import {hasSelection, getSelection, DOMSelectionState, isEquivalentPosition, deepActiveElement, dispatchKey, atElementStart} from "./dom" import {DOMChange, applyDOMChange} from "./domchange" const observeOptions = { childList: true, characterData: true, subtree: true, attributes: true, characterDataOldValue: true } // IE11 has very broken mutation observers, so we also listen to // DOMCharacterDataModified there const useCharData = browser.ie && browser.ie_version <= 11 export class DOMObserver { dom: HTMLElement win: Window observer: MutationObserver active: boolean = false // The known selection. Kept in our own object, as opposed to just // directly accessing the selection because: // - Safari doesn't report the right selection in shadow DOM // - Reading from the selection forces a DOM layout // - This way, we can ignore selectionchange events if we have // already seen the 'new' selection selectionRange: DOMSelectionState = new DOMSelectionState // Set when a selection change is detected, cleared on flush selectionChanged = false delayedFlush = -1 resizeTimeout = -1 queue: MutationRecord[] = [] delayedAndroidKey: {key: string, keyCode: number, force: boolean} | null = null flushingAndroidKey = -1 lastChange = 0 onCharData: any scrollTargets: HTMLElement[] = [] intersection: IntersectionObserver | null = null resizeScroll: ResizeObserver | null = null intersecting: boolean = false gapIntersection: IntersectionObserver | null = null gaps: readonly HTMLElement[] = [] printQuery: MediaQueryList | null = null // Timeout for scheduling check of the parents that need scroll handlers parentCheck = -1 constructor(private view: EditorView) { this.dom = view.contentDOM this.observer = new MutationObserver(mutations => { for (let mut of mutations) this.queue.push(mut) // IE11 will sometimes (on typing over a selection or // backspacing out a single character text node) call the // observer callback before actually updating the DOM. // // Unrelatedly, iOS Safari will, when ending a composition, // sometimes first clear it, deliver the mutations, and then // reinsert the finished text. CodeMirror's handling of the // deletion will prevent the reinsertion from happening, // breaking composition. if ((browser.ie && browser.ie_version <= 11 || browser.ios && view.composing) && mutations.some(m => m.type == "childList" && m.removedNodes.length || m.type == "characterData" && m.oldValue!.length > m.target.nodeValue!.length)) this.flushSoon() else this.flush() }) if (useCharData) this.onCharData = (event: MutationEvent) => { this.queue.push({target: event.target, type: "characterData", oldValue: event.prevValue} as MutationRecord) this.flushSoon() } this.onSelectionChange = this.onSelectionChange.bind(this) this.onResize = this.onResize.bind(this) this.onPrint = this.onPrint.bind(this) this.onScroll = this.onScroll.bind(this) if (window.matchMedia) this.printQuery = window.matchMedia("print") if (typeof ResizeObserver == "function") { this.resizeScroll = new ResizeObserver(() => { if (this.view.docView?.lastUpdate < Date.now() - 75) this.onResize() }) this.resizeScroll.observe(view.scrollDOM) } this.addWindowListeners(this.win = view.win) this.start() if (typeof IntersectionObserver == "function") { this.intersection = new IntersectionObserver(entries => { if (this.parentCheck < 0) this.parentCheck = setTimeout(this.listenForScroll.bind(this), 1000) if (entries.length > 0 && (entries[entries.length - 1].intersectionRatio > 0) != this.intersecting) { this.intersecting = !this.intersecting if (this.intersecting != this.view.inView) this.onScrollChanged(document.createEvent("Event")) } }, {threshold: [0, .001]}) this.intersection.observe(this.dom) this.gapIntersection = new IntersectionObserver(entries => { if (entries.length > 0 && entries[entries.length - 1].intersectionRatio > 0) this.onScrollChanged(document.createEvent("Event")); }, {}) } this.listenForScroll() this.readSelectionRange() } onScrollChanged(e: Event) { this.view.inputState.runHandlers("scroll", e) if (this.intersecting) this.view.measure() } onScroll(e: Event) { if (this.intersecting) this.flush(false) this.onScrollChanged(e) } onResize() { if (this.resizeTimeout < 0) this.resizeTimeout = setTimeout(() => { this.resizeTimeout = -1 this.view.requestMeasure() }, 50) } onPrint(event: Event) { if (event.type == "change" && !(event as MediaQueryListEvent).matches) return this.view.viewState.printing = true this.view.measure() setTimeout(() => { this.view.viewState.printing = false this.view.requestMeasure() }, 500) } updateGaps(gaps: readonly HTMLElement[]) { if (this.gapIntersection && (gaps.length != this.gaps.length || this.gaps.some((g, i) => g != gaps[i]))) { this.gapIntersection.disconnect() for (let gap of gaps) this.gapIntersection.observe(gap) this.gaps = gaps } } onSelectionChange(event: Event) { let wasChanged = this.selectionChanged if (!this.readSelectionRange() || this.delayedAndroidKey) return let {view} = this, sel = this.selectionRange if (view.state.facet(editable) ? view.root.activeElement != this.dom : !hasSelection(view.dom, sel)) return let context = sel.anchorNode && view.docView.nearest(sel.anchorNode) if (context && context.ignoreEvent(event)) { if (!wasChanged) this.selectionChanged = false return } // Deletions on IE11 fire their events in the wrong order, giving // us a selection change event before the DOM changes are // reported. // Chrome Android has a similar issue when backspacing out a // selection (#645). if ((browser.ie && browser.ie_version <= 11 || browser.android && browser.chrome) && !view.state.selection.main.empty && // (Selection.isCollapsed isn't reliable on IE) sel.focusNode && isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode, sel.anchorOffset)) this.flushSoon() else this.flush(false) } readSelectionRange() { let {view} = this // The Selection object is broken in shadow roots in Safari. See // https://github.com/codemirror/dev/issues/414 let selection = getSelection(view.root) if (!selection) return false let range = browser.safari && (view.root as any).nodeType == 11 && deepActiveElement(this.dom.ownerDocument) == this.dom && safariSelectionRangeHack(this.view, selection) || selection if (!range || this.selectionRange.eq(range)) return false let local = hasSelection(this.dom, range) // Detect the situation where the browser has, on focus, moved the // selection to the start of the content element. Reset it to the // position from the editor state. if (local && !this.selectionChanged && view.inputState.lastFocusTime > Date.now() - 200 && view.inputState.lastTouchTime < Date.now() - 300 && atElementStart(this.dom, range)) { this.view.inputState.lastFocusTime = 0 view.docView.updateSelection() return false } this.selectionRange.setRange(range) if (local) this.selectionChanged = true return true } setSelectionRange(anchor: {node: Node, offset: number}, head: {node: Node, offset: number}) { this.selectionRange.set(anchor.node, anchor.offset, head.node, head.offset) this.selectionChanged = false } clearSelectionRange() { this.selectionRange.set(null, 0, null, 0) } listenForScroll() { this.parentCheck = -1 let i = 0, changed: HTMLElement[] | null = null for (let dom = this.dom as any; dom;) { if (dom.nodeType == 1) { if (!changed && i < this.scrollTargets.length && this.scrollTargets[i] == dom) i++ else if (!changed) changed = this.scrollTargets.slice(0, i) if (changed) changed.push(dom) dom = dom.assignedSlot || dom.parentNode } else if (dom.nodeType == 11) { // Shadow root dom = dom.host } else { break } } if (i < this.scrollTargets.length && !changed) changed = this.scrollTargets.slice(0, i) if (changed) { for (let dom of this.scrollTargets) dom.removeEventListener("scroll", this.onScroll) for (let dom of this.scrollTargets = changed) dom.addEventListener("scroll", this.onScroll) } } ignore(f: () => T): T { if (!this.active) return f() try { this.stop() return f() } finally { this.start() this.clear() } } start() { if (this.active) return this.observer.observe(this.dom, observeOptions) if (useCharData) this.dom.addEventListener("DOMCharacterDataModified", this.onCharData) this.active = true } stop() { if (!this.active) return this.active = false this.observer.disconnect() if (useCharData) this.dom.removeEventListener("DOMCharacterDataModified", this.onCharData) } // Throw away any pending changes clear() { this.processRecords() this.queue.length = 0 this.selectionChanged = false } // Chrome Android, especially in combination with GBoard, not only // doesn't reliably fire regular key events, but also often // surrounds the effect of enter or backspace with a bunch of // composition events that, when interrupted, cause text duplication // or other kinds of corruption. This hack makes the editor back off // from handling DOM changes for a moment when such a key is // detected (via beforeinput or keydown), and then tries to flush // them or, if that has no effect, dispatches the given key. delayAndroidKey(key: string, keyCode: number) { if (!this.delayedAndroidKey) { let flush = () => { let key = this.delayedAndroidKey if (key) { this.clearDelayedAndroidKey() this.view.inputState.lastKeyCode = key.keyCode this.view.inputState.lastKeyTime = Date.now() let flushed = this.flush() if (!flushed && key.force) dispatchKey(this.dom, key.key, key.keyCode) } } this.flushingAndroidKey = this.view.win.requestAnimationFrame(flush) } // Since backspace beforeinput is sometimes signalled spuriously, // Enter always takes precedence. if (!this.delayedAndroidKey || key == "Enter") this.delayedAndroidKey = { key, keyCode, // Only run the key handler when no changes are detected if // this isn't coming right after another change, in which case // it is probably part of a weird chain of updates, and should // be ignored if it returns the DOM to its previous state. force: this.lastChange < Date.now() - 50 || !!this.delayedAndroidKey?.force } } clearDelayedAndroidKey() { this.win.cancelAnimationFrame(this.flushingAndroidKey) this.delayedAndroidKey = null this.flushingAndroidKey = -1 } flushSoon() { if (this.delayedFlush < 0) this.delayedFlush = this.view.win.requestAnimationFrame(() => { this.delayedFlush = -1; this.flush() }) } forceFlush() { if (this.delayedFlush >= 0) { this.view.win.cancelAnimationFrame(this.delayedFlush) this.delayedFlush = -1 } this.flush() } pendingRecords() { for (let mut of this.observer.takeRecords()) this.queue.push(mut) return this.queue } processRecords() { let records = this.pendingRecords() if (records.length) this.queue = [] let from = -1, to = -1, typeOver = false for (let record of records) { let range = this.readMutation(record) if (!range) continue if (range.typeOver) typeOver = true if (from == -1) { ;({from, to} = range) } else { from = Math.min(range.from, from) to = Math.max(range.to, to) } } return {from, to, typeOver} } readChange() { let {from, to, typeOver} = this.processRecords() let newSel = this.selectionChanged && hasSelection(this.dom, this.selectionRange) if (from < 0 && !newSel) return null if (from > -1) this.lastChange = Date.now() this.view.inputState.lastFocusTime = 0 this.selectionChanged = false let change = new DOMChange(this.view, from, to, typeOver) this.view.docView.domChanged = {newSel: change.newSel ? change.newSel.main : null} return change } // Apply pending changes, if any flush(readSelection = true) { // Completely hold off flushing when pending keys are set—the code // managing those will make sure processRecords is called and the // view is resynchronized after if (this.delayedFlush >= 0 || this.delayedAndroidKey) return false if (readSelection) this.readSelectionRange() let domChange = this.readChange() if (!domChange) { this.view.requestMeasure() return false } let startState = this.view.state let handled = applyDOMChange(this.view, domChange) // The view wasn't updated if (this.view.state == startState) this.view.update([]) return handled } readMutation(rec: MutationRecord): {from: number, to: number, typeOver: boolean} | null { let cView = this.view.docView.nearest(rec.target) if (!cView || cView.ignoreMutation(rec)) return null cView.markDirty(rec.type == "attributes") if (rec.type == "attributes") cView.flags |= ViewFlag.AttrsDirty if (rec.type == "childList") { let childBefore = findChild(cView, rec.previousSibling || rec.target.previousSibling, -1) let childAfter = findChild(cView, rec.nextSibling || rec.target.nextSibling, 1) return {from: childBefore ? cView.posAfter(childBefore) : cView.posAtStart, to: childAfter ? cView.posBefore(childAfter) : cView.posAtEnd, typeOver: false} } else if (rec.type == "characterData") { return {from: cView.posAtStart, to: cView.posAtEnd, typeOver: rec.target.nodeValue == rec.oldValue} } else { return null } } setWindow(win: Window) { if (win != this.win) { this.removeWindowListeners(this.win) this.win = win this.addWindowListeners(this.win) } } addWindowListeners(win: Window) { win.addEventListener("resize", this.onResize) if (this.printQuery) this.printQuery.addEventListener("change", this.onPrint) else win.addEventListener("beforeprint", this.onPrint) win.addEventListener("scroll", this.onScroll) win.document.addEventListener("selectionchange", this.onSelectionChange) } removeWindowListeners(win: Window) { win.removeEventListener("scroll", this.onScroll) win.removeEventListener("resize", this.onResize) if (this.printQuery) this.printQuery.removeEventListener("change", this.onPrint) else win.removeEventListener("beforeprint", this.onPrint) win.document.removeEventListener("selectionchange", this.onSelectionChange) } destroy() { this.stop() this.intersection?.disconnect() this.gapIntersection?.disconnect() this.resizeScroll?.disconnect() for (let dom of this.scrollTargets) dom.removeEventListener("scroll", this.onScroll) this.removeWindowListeners(this.win) clearTimeout(this.parentCheck) clearTimeout(this.resizeTimeout) this.win.cancelAnimationFrame(this.delayedFlush) this.win.cancelAnimationFrame(this.flushingAndroidKey) } } function findChild(cView: ContentView, dom: Node | null, dir: number): ContentView | null { while (dom) { let curView = ContentView.get(dom) if (curView && curView.parent == cView) return curView let parent = dom.parentNode dom = parent != cView.dom ? parent : dir > 0 ? dom.nextSibling : dom.previousSibling } return null } function buildSelectionRangeFromRange(view: EditorView, range: StaticRange) { let anchorNode = range.startContainer, anchorOffset = range.startOffset let focusNode = range.endContainer, focusOffset = range.endOffset let curAnchor = view.docView.domAtPos(view.state.selection.main.anchor) // Since such a range doesn't distinguish between anchor and head, // use a heuristic that flips it around if its end matches the // current anchor. if (isEquivalentPosition(curAnchor.node, curAnchor.offset, focusNode, focusOffset)) [anchorNode, anchorOffset, focusNode, focusOffset] = [focusNode, focusOffset, anchorNode, anchorOffset] return {anchorNode, anchorOffset, focusNode, focusOffset} } // Used to work around a Safari Selection/shadow DOM bug (#414) function safariSelectionRangeHack(view: EditorView, selection: Selection) { if ((selection as any).getComposedRanges) { let range = (selection as any).getComposedRanges(view.root)[0] as StaticRange if (range) return buildSelectionRangeFromRange(view, range) } let found = null as null | StaticRange // Because Safari (at least in 2018-2021) doesn't provide regular // access to the selection inside a shadowroot, we have to perform a // ridiculous hack to get at it—using `execCommand` to trigger a // `beforeInput` event so that we can read the target range from the // event. function read(event: InputEvent) { event.preventDefault() event.stopImmediatePropagation() found = (event as any).getTargetRanges()[0] } view.contentDOM.addEventListener("beforeinput", read, true) view.dom.ownerDocument.execCommand("indent") view.contentDOM.removeEventListener("beforeinput", read, true) return found ? buildSelectionRangeFromRange(view, found) : null } view-6.26.3/src/domreader.ts000066400000000000000000000067371460616253600157110ustar00rootroot00000000000000import {ContentView} from "./contentview" import {domIndex, maxOffset, isBlockElement} from "./dom" import {EditorState} from "@codemirror/state" export const LineBreakPlaceholder = "\uffff" export class DOMReader { text: string = "" lineSeparator: string | undefined constructor(private points: DOMPoint[], state: EditorState) { this.lineSeparator = state.facet(EditorState.lineSeparator) } append(text: string) { this.text += text } lineBreak() { this.text += LineBreakPlaceholder } readRange(start: Node | null, end: Node | null) { if (!start) return this let parent = start.parentNode! for (let cur = start;;) { this.findPointBefore(parent, cur) let oldLen = this.text.length this.readNode(cur) let next: Node | null = cur.nextSibling if (next == end) break let view = ContentView.get(cur), nextView = ContentView.get(next!) if (view && nextView ? view.breakAfter : (view ? view.breakAfter : isBlockElement(cur)) || (isBlockElement(next!) && (cur.nodeName != "BR" || (cur as any).cmIgnore) && this.text.length > oldLen)) this.lineBreak() cur = next! } this.findPointBefore(parent, end) return this } readTextNode(node: Text) { let text = node.nodeValue! for (let point of this.points) if (point.node == node) point.pos = this.text.length + Math.min(point.offset, text.length) for (let off = 0, re = this.lineSeparator ? null : /\r\n?|\n/g;;) { let nextBreak = -1, breakSize = 1, m if (this.lineSeparator) { nextBreak = text.indexOf(this.lineSeparator, off) breakSize = this.lineSeparator.length } else if (m = re!.exec(text)) { nextBreak = m.index breakSize = m[0].length } this.append(text.slice(off, nextBreak < 0 ? text.length : nextBreak)) if (nextBreak < 0) break this.lineBreak() if (breakSize > 1) for (let point of this.points) if (point.node == node && point.pos > this.text.length) point.pos -= breakSize - 1 off = nextBreak + breakSize } } readNode(node: Node) { if ((node as any).cmIgnore) return let view = ContentView.get(node) let fromView = view && view.overrideDOMText if (fromView != null) { this.findPointInside(node, fromView.length) for (let i = fromView.iter(); !i.next().done;) { if (i.lineBreak) this.lineBreak() else this.append(i.value) } } else if (node.nodeType == 3) { this.readTextNode(node as Text) } else if (node.nodeName == "BR") { if (node.nextSibling) this.lineBreak() } else if (node.nodeType == 1) { this.readRange(node.firstChild, null) } } findPointBefore(node: Node, next: Node | null) { for (let point of this.points) if (point.node == node && node.childNodes[point.offset] == next) point.pos = this.text.length } findPointInside(node: Node, length: number) { for (let point of this.points) if (node.nodeType == 3 ? point.node == node : node.contains(point.node)) point.pos = this.text.length + (isAtEnd(node, point.node, point.offset) ? length : 0) } } function isAtEnd(parent: Node, node: Node | null, offset: number) { for (;;) { if (!node || offset < maxOffset(node)) return false if (node == parent) return true offset = domIndex(node) + 1 node = node.parentNode } } export class DOMPoint { pos: number = -1 constructor(readonly node: Node, readonly offset: number) {} } view-6.26.3/src/draw-selection.ts000066400000000000000000000106441460616253600166570ustar00rootroot00000000000000import {EditorSelection, Extension, Facet, combineConfig, Prec, EditorState} from "@codemirror/state" import {StyleSpec} from "style-mod" import {ViewUpdate, nativeSelectionHidden} from "./extension" import {EditorView} from "./editorview" import {layer, RectangleMarker} from "./layer" import browser from "./browser" const CanHidePrimary = !browser.ios // FIXME test IE type SelectionConfig = { /// The length of a full cursor blink cycle, in milliseconds. /// Defaults to 1200. Can be set to 0 to disable blinking. cursorBlinkRate?: number /// Whether to show a cursor for non-empty ranges. Defaults to /// true. drawRangeCursor?: boolean } const selectionConfig = Facet.define>({ combine(configs) { return combineConfig(configs, { cursorBlinkRate: 1200, drawRangeCursor: true }, { cursorBlinkRate: (a, b) => Math.min(a, b), drawRangeCursor: (a, b) => a || b }) } }) /// Returns an extension that hides the browser's native selection and /// cursor, replacing the selection with a background behind the text /// (with the `cm-selectionBackground` class), and the /// cursors with elements overlaid over the code (using /// `cm-cursor-primary` and `cm-cursor-secondary`). /// /// This allows the editor to display secondary selection ranges, and /// tends to produce a type of selection more in line with that users /// expect in a text editor (the native selection styling will often /// leave gaps between lines and won't fill the horizontal space after /// a line when the selection continues past it). /// /// It does have a performance cost, in that it requires an extra DOM /// layout cycle for many updates (the selection is drawn based on DOM /// layout information that's only available after laying out the /// content). export function drawSelection(config: SelectionConfig = {}): Extension { return [ selectionConfig.of(config), cursorLayer, selectionLayer, hideNativeSelection, nativeSelectionHidden.of(true) ] } /// Retrieve the [`drawSelection`](#view.drawSelection) configuration /// for this state. (Note that this will return a set of defaults even /// if `drawSelection` isn't enabled.) export function getDrawSelectionConfig(state: EditorState): SelectionConfig { return state.facet(selectionConfig) } function configChanged(update: ViewUpdate) { return update.startState.facet(selectionConfig) != update.state.facet(selectionConfig) } const cursorLayer = layer({ above: true, markers(view) { let {state} = view, conf = state.facet(selectionConfig) let cursors = [] for (let r of state.selection.ranges) { let prim = r == state.selection.main if (r.empty ? !prim || CanHidePrimary : conf.drawRangeCursor) { let className = prim ? "cm-cursor cm-cursor-primary" : "cm-cursor cm-cursor-secondary" let cursor = r.empty ? r : EditorSelection.cursor(r.head, r.head > r.anchor ? -1 : 1) for (let piece of RectangleMarker.forRange(view, className, cursor)) cursors.push(piece) } } return cursors }, update(update, dom) { if (update.transactions.some(tr => tr.selection)) dom.style.animationName = dom.style.animationName == "cm-blink" ? "cm-blink2" : "cm-blink" let confChange = configChanged(update) if (confChange) setBlinkRate(update.state, dom) return update.docChanged || update.selectionSet || confChange }, mount(dom, view) { setBlinkRate(view.state, dom) }, class: "cm-cursorLayer" }) function setBlinkRate(state: EditorState, dom: HTMLElement) { dom.style.animationDuration = state.facet(selectionConfig).cursorBlinkRate + "ms" } const selectionLayer = layer({ above: false, markers(view) { return view.state.selection.ranges.map(r => r.empty ? [] : RectangleMarker.forRange(view, "cm-selectionBackground", r)) .reduce((a, b) => a.concat(b)) }, update(update, dom) { return update.docChanged || update.selectionSet || update.viewportChanged || configChanged(update) }, class: "cm-selectionLayer" }) const themeSpec: {[selector: string]: StyleSpec} = { ".cm-line": { "& ::selection": {backgroundColor: "transparent !important"}, "&::selection": {backgroundColor: "transparent !important"} } } if (CanHidePrimary) { themeSpec[".cm-line"].caretColor = "transparent !important" themeSpec[".cm-content"] = {caretColor: "transparent !important"} } const hideNativeSelection = Prec.highest(EditorView.theme(themeSpec)) view-6.26.3/src/dropcursor.ts000066400000000000000000000061021460616253600161330ustar00rootroot00000000000000import {StateField, StateEffect, Extension} from "@codemirror/state" import {EditorView} from "./editorview" import {ViewPlugin, MeasureRequest, ViewUpdate} from "./extension" const setDropCursorPos = StateEffect.define({ map(pos, mapping) { return pos == null ? null : mapping.mapPos(pos) } }) const dropCursorPos = StateField.define({ create() { return null }, update(pos, tr) { if (pos != null) pos = tr.changes.mapPos(pos) return tr.effects.reduce((pos, e) => e.is(setDropCursorPos) ? e.value : pos, pos) } }) const drawDropCursor = ViewPlugin.fromClass(class { cursor: HTMLElement | null = null measureReq: MeasureRequest<{left: number, top: number, height: number} | null> constructor(readonly view: EditorView) { this.measureReq = {read: this.readPos.bind(this), write: this.drawCursor.bind(this)} } update(update: ViewUpdate) { let cursorPos = update.state.field(dropCursorPos) if (cursorPos == null) { if (this.cursor != null) { this.cursor?.remove() this.cursor = null } } else { if (!this.cursor) { this.cursor = this.view.scrollDOM.appendChild(document.createElement("div")) this.cursor!.className = "cm-dropCursor" } if (update.startState.field(dropCursorPos) != cursorPos || update.docChanged || update.geometryChanged) this.view.requestMeasure(this.measureReq) } } readPos(): {left: number, top: number, height: number} | null { let {view} = this let pos = view.state.field(dropCursorPos) let rect = pos != null && view.coordsAtPos(pos) if (!rect) return null let outer = view.scrollDOM.getBoundingClientRect() return { left: rect.left - outer.left + view.scrollDOM.scrollLeft * view.scaleX, top: rect.top - outer.top + view.scrollDOM.scrollTop * view.scaleY, height: rect.bottom - rect.top } } drawCursor(pos: {left: number, top: number, height: number} | null) { if (this.cursor) { let {scaleX, scaleY} = this.view if (pos) { this.cursor.style.left = pos.left / scaleX + "px" this.cursor.style.top = pos.top / scaleY + "px" this.cursor.style.height = pos.height / scaleY + "px" } else { this.cursor.style.left = "-100000px" } } } destroy() { if (this.cursor) this.cursor.remove() } setDropPos(pos: number | null) { if (this.view.state.field(dropCursorPos) != pos) this.view.dispatch({effects: setDropCursorPos.of(pos)}) } }, { eventObservers: { dragover(event) { this.setDropPos(this.view.posAtCoords({x: event.clientX, y: event.clientY})) }, dragleave(event) { if (event.target == this.view.contentDOM || !this.view.contentDOM.contains(event.relatedTarget as HTMLElement)) this.setDropPos(null) }, dragend() { this.setDropPos(null) }, drop() { this.setDropPos(null) } } }) /// Draws a cursor at the current drop position when something is /// dragged over the editor. export function dropCursor(): Extension { return [dropCursorPos, drawDropCursor] } view-6.26.3/src/editorview.ts000066400000000000000000001457121460616253600161250ustar00rootroot00000000000000import {EditorState, Transaction, TransactionSpec, Extension, Prec, ChangeDesc, EditorSelection, SelectionRange, StateEffect, Facet, Line, EditorStateConfig} from "@codemirror/state" import {StyleModule, StyleSpec} from "style-mod" import {DocView} from "./docview" import {ContentView} from "./contentview" import {InputState, focusChangeTransaction, isFocusChange} from "./input" import {Rect, focusPreventScroll, flattenRect, getRoot, ScrollStrategy, isScrolledToBottom, dispatchKey} from "./dom" import {posAtCoords, moveByChar, moveToLineBoundary, byGroup, moveVertically, skipAtoms} from "./cursor" import {BlockInfo} from "./heightmap" import {ViewState} from "./viewstate" import {ViewUpdate, styleModule, contentAttributes, editorAttributes, AttrSource, clickAddsSelectionRange, dragMovesSelection, mouseSelectionStyle, exceptionSink, updateListener, logException, viewPlugin, ViewPlugin, PluginValue, PluginInstance, decorations, outerDecorations, atomicRanges, scrollMargins, MeasureRequest, editable, inputHandler, focusChangeEffect, perLineTextDirection, scrollIntoView, UpdateFlag, ScrollTarget, bidiIsolatedRanges, getIsolatedRanges, scrollHandler} from "./extension" import {theme, darkTheme, buildTheme, baseThemeID, baseLightID, baseDarkID, lightDarkIDs, baseTheme} from "./theme" import {DOMObserver} from "./domobserver" import {Attrs, updateAttrs, combineAttrs} from "./attributes" import browser from "./browser" import {computeOrder, trivialOrder, BidiSpan, Direction, Isolate, isolatesEq} from "./bidi" import {applyDOMChange, DOMChange} from "./domchange" /// The type of object given to the [`EditorView`](#view.EditorView) /// constructor. export interface EditorViewConfig extends EditorStateConfig { /// The view's initial state. If not given, a new state is created /// by passing this configuration object to /// [`EditorState.create`](#state.EditorState^create), using its /// `doc`, `selection`, and `extensions` field (if provided). state?: EditorState, /// When given, the editor is immediately appended to the given /// element on creation. (Otherwise, you'll have to place the view's /// [`dom`](#view.EditorView.dom) element in the document yourself.) parent?: Element | DocumentFragment /// If the view is going to be mounted in a shadow root or document /// other than the one held by the global variable `document` (the /// default), you should pass it here. If you provide `parent`, but /// not this option, the editor will automatically look up a root /// from the parent. root?: Document | ShadowRoot, /// Pass an effect created with /// [`EditorView.scrollIntoView`](#view.EditorView^scrollIntoView) or /// [`EditorView.scrollSnapshot`](#view.EditorView.scrollSnapshot) /// here to set an initial scroll position. scrollTo?: StateEffect, /// Override the way transactions are /// [dispatched](#view.EditorView.dispatch) for this editor view. /// Your implementation, if provided, should probably call the /// view's [`update` method](#view.EditorView.update). dispatchTransactions?: (trs: readonly Transaction[], view: EditorView) => void /// **Deprecated** single-transaction version of /// `dispatchTransactions`. Will force transactions to be dispatched /// one at a time when used. dispatch?: (tr: Transaction, view: EditorView) => void } export const enum UpdateState { Idle, // Not updating Measuring, // In the layout-reading phase of a layout check Updating // Updating/drawing, either directly via the `update` method, or as a result of a layout check } // The editor's update state machine looks something like this: // // Idle → Updating ⇆ Idle (unchecked) → Measuring → Idle // ↑ ↓ // Updating (measure) // // The difference between 'Idle' and 'Idle (unchecked)' lies in // whether a layout check has been scheduled. A regular update through // the `update` method updates the DOM in a write-only fashion, and // relies on a check (scheduled with `requestAnimationFrame`) to make // sure everything is where it should be and the viewport covers the // visible code. That check continues to measure and then optionally // update until it reaches a coherent state. /// An editor view represents the editor's user interface. It holds /// the editable DOM surface, and possibly other elements such as the /// line number gutter. It handles events and dispatches state /// transactions for editing actions. export class EditorView { /// The current editor state. get state() { return this.viewState.state } /// To be able to display large documents without consuming too much /// memory or overloading the browser, CodeMirror only draws the /// code that is visible (plus a margin around it) to the DOM. This /// property tells you the extent of the current drawn viewport, in /// document positions. get viewport(): {from: number, to: number} { return this.viewState.viewport } /// When there are, for example, large collapsed ranges in the /// viewport, its size can be a lot bigger than the actual visible /// content. Thus, if you are doing something like styling the /// content in the viewport, it is preferable to only do so for /// these ranges, which are the subset of the viewport that is /// actually drawn. get visibleRanges(): readonly {from: number, to: number}[] { return this.viewState.visibleRanges } /// Returns false when the editor is entirely scrolled out of view /// or otherwise hidden. get inView() { return this.viewState.inView } /// Indicates whether the user is currently composing text via /// [IME](https://en.wikipedia.org/wiki/Input_method), and at least /// one change has been made in the current composition. get composing() { return this.inputState.composing > 0 } /// Indicates whether the user is currently in composing state. Note /// that on some platforms, like Android, this will be the case a /// lot, since just putting the cursor on a word starts a /// composition there. get compositionStarted() { return this.inputState.composing >= 0 } private dispatchTransactions: (trs: readonly Transaction[], view: EditorView) => void private _root: DocumentOrShadowRoot /// The document or shadow root that the view lives in. get root() { return this._root } /// @internal get win() { return this.dom.ownerDocument.defaultView || window } /// The DOM element that wraps the entire editor view. readonly dom: HTMLElement /// The DOM element that can be styled to scroll. (Note that it may /// not have been, so you can't assume this is scrollable.) readonly scrollDOM: HTMLElement /// The editable DOM element holding the editor content. You should /// not, usually, interact with this content directly though the /// DOM, since the editor will immediately undo most of the changes /// you make. Instead, [dispatch](#view.EditorView.dispatch) /// [transactions](#state.Transaction) to modify content, and /// [decorations](#view.Decoration) to style it. readonly contentDOM: HTMLElement private announceDOM: HTMLElement /// @internal inputState!: InputState /// @internal public viewState: ViewState /// @internal public docView: DocView private plugins: PluginInstance[] = [] private pluginMap: Map, PluginInstance | null> = new Map private editorAttrs: Attrs = {} private contentAttrs: Attrs = {} private styleModules!: readonly StyleModule[] private bidiCache: CachedOrder[] = [] private destroyed = false; /// @internal updateState: UpdateState = UpdateState.Updating /// @internal observer: DOMObserver /// @internal measureScheduled: number = -1 /// @internal measureRequests: MeasureRequest[] = [] /// Construct a new view. You'll want to either provide a `parent` /// option, or put `view.dom` into your document after creating a /// view, so that the user can see the editor. constructor(config: EditorViewConfig = {}) { this.contentDOM = document.createElement("div") this.scrollDOM = document.createElement("div") this.scrollDOM.tabIndex = -1 this.scrollDOM.className = "cm-scroller" this.scrollDOM.appendChild(this.contentDOM) this.announceDOM = document.createElement("div") this.announceDOM.className = "cm-announced" this.announceDOM.setAttribute("aria-live", "polite") this.dom = document.createElement("div") this.dom.appendChild(this.announceDOM) this.dom.appendChild(this.scrollDOM) if (config.parent) config.parent.appendChild(this.dom) let {dispatch} = config this.dispatchTransactions = config.dispatchTransactions || (dispatch && ((trs: readonly Transaction[]) => trs.forEach(tr => dispatch!(tr, this)))) || ((trs: readonly Transaction[]) => this.update(trs)) this.dispatch = this.dispatch.bind(this) this._root = (config.root || getRoot(config.parent) || document) as DocumentOrShadowRoot this.viewState = new ViewState(config.state || EditorState.create(config)) if (config.scrollTo && config.scrollTo.is(scrollIntoView)) this.viewState.scrollTarget = config.scrollTo.value.clip(this.viewState.state) this.plugins = this.state.facet(viewPlugin).map(spec => new PluginInstance(spec)) for (let plugin of this.plugins) plugin.update(this) this.observer = new DOMObserver(this) this.inputState = new InputState(this) this.inputState.ensureHandlers(this.plugins) this.docView = new DocView(this) this.mountStyles() this.updateAttrs() this.updateState = UpdateState.Idle this.requestMeasure() } /// All regular editor state updates should go through this. It /// takes a transaction, array of transactions, or transaction spec /// and updates the view to show the new state produced by that /// transaction. Its implementation can be overridden with an /// [option](#view.EditorView.constructor^config.dispatchTransactions). /// This function is bound to the view instance, so it does not have /// to be called as a method. /// /// Note that when multiple `TransactionSpec` arguments are /// provided, these define a single transaction (the specs will be /// merged), not a sequence of transactions. dispatch(tr: Transaction): void dispatch(trs: readonly Transaction[]): void dispatch(...specs: TransactionSpec[]): void dispatch(...input: (Transaction | readonly Transaction[] | TransactionSpec)[]) { let trs = input.length == 1 && input[0] instanceof Transaction ? input as readonly Transaction[] : input.length == 1 && Array.isArray(input[0]) ? input[0] as readonly Transaction[] : [this.state.update(...input as TransactionSpec[])] this.dispatchTransactions(trs, this) } /// Update the view for the given array of transactions. This will /// update the visible document and selection to match the state /// produced by the transactions, and notify view plugins of the /// change. You should usually call /// [`dispatch`](#view.EditorView.dispatch) instead, which uses this /// as a primitive. update(transactions: readonly Transaction[]) { if (this.updateState != UpdateState.Idle) throw new Error("Calls to EditorView.update are not allowed while an update is in progress") let redrawn = false, attrsChanged = false, update: ViewUpdate let state = this.state for (let tr of transactions) { if (tr.startState != state) throw new RangeError("Trying to update state with a transaction that doesn't start from the previous state.") state = tr.state } if (this.destroyed) { this.viewState.state = state return } let focus = this.hasFocus, focusFlag = 0, dispatchFocus: Transaction | null = null if (transactions.some(tr => tr.annotation(isFocusChange))) { this.inputState.notifiedFocused = focus // If a focus-change transaction is being dispatched, set this update flag. focusFlag = UpdateFlag.Focus } else if (focus != this.inputState.notifiedFocused) { this.inputState.notifiedFocused = focus // Schedule a separate focus transaction if necessary, otherwise // add a flag to this update dispatchFocus = focusChangeTransaction(state, focus) if (!dispatchFocus) focusFlag = UpdateFlag.Focus } // If there was a pending DOM change, eagerly read it and try to // apply it after the given transactions. let pendingKey = this.observer.delayedAndroidKey, domChange: DOMChange | null = null if (pendingKey) { this.observer.clearDelayedAndroidKey() domChange = this.observer.readChange() // Only try to apply DOM changes if the transactions didn't // change the doc or selection. if (domChange && !this.state.doc.eq(state.doc) || !this.state.selection.eq(state.selection)) domChange = null } else { this.observer.clear() } // When the phrases change, redraw the editor if (state.facet(EditorState.phrases) != this.state.facet(EditorState.phrases)) return this.setState(state) update = ViewUpdate.create(this, state, transactions) update.flags |= focusFlag let scrollTarget = this.viewState.scrollTarget try { this.updateState = UpdateState.Updating for (let tr of transactions) { if (scrollTarget) scrollTarget = scrollTarget.map(tr.changes) if (tr.scrollIntoView) { let {main} = tr.state.selection scrollTarget = new ScrollTarget( main.empty ? main : EditorSelection.cursor(main.head, main.head > main.anchor ? -1 : 1)) } for (let e of tr.effects) if (e.is(scrollIntoView)) scrollTarget = e.value.clip(this.state) } this.viewState.update(update, scrollTarget) this.bidiCache = CachedOrder.update(this.bidiCache, update.changes) if (!update.empty) { this.updatePlugins(update) this.inputState.update(update) } redrawn = this.docView.update(update) if (this.state.facet(styleModule) != this.styleModules) this.mountStyles() attrsChanged = this.updateAttrs() this.showAnnouncements(transactions) this.docView.updateSelection(redrawn, transactions.some(tr => tr.isUserEvent("select.pointer"))) } finally { this.updateState = UpdateState.Idle } if (update.startState.facet(theme) != update.state.facet(theme)) this.viewState.mustMeasureContent = true if (redrawn || attrsChanged || scrollTarget || this.viewState.mustEnforceCursorAssoc || this.viewState.mustMeasureContent) this.requestMeasure() if (redrawn) this.docViewUpdate() if (!update.empty) for (let listener of this.state.facet(updateListener)) { try { listener(update) } catch (e) { logException(this.state, e, "update listener") } } if (dispatchFocus || domChange) Promise.resolve().then(() => { if (dispatchFocus && this.state == dispatchFocus.startState) this.dispatch(dispatchFocus) if (domChange) { if (!applyDOMChange(this, domChange) && pendingKey!.force) dispatchKey(this.contentDOM, pendingKey!.key, pendingKey!.keyCode) } }) } /// Reset the view to the given state. (This will cause the entire /// document to be redrawn and all view plugins to be reinitialized, /// so you should probably only use it when the new state isn't /// derived from the old state. Otherwise, use /// [`dispatch`](#view.EditorView.dispatch) instead.) setState(newState: EditorState) { if (this.updateState != UpdateState.Idle) throw new Error("Calls to EditorView.setState are not allowed while an update is in progress") if (this.destroyed) { this.viewState.state = newState return } this.updateState = UpdateState.Updating let hadFocus = this.hasFocus try { for (let plugin of this.plugins) plugin.destroy(this) this.viewState = new ViewState(newState) this.plugins = newState.facet(viewPlugin).map(spec => new PluginInstance(spec)) this.pluginMap.clear() for (let plugin of this.plugins) plugin.update(this) this.docView.destroy() this.docView = new DocView(this) this.inputState.ensureHandlers(this.plugins) this.mountStyles() this.updateAttrs() this.bidiCache = [] } finally { this.updateState = UpdateState.Idle } if (hadFocus) this.focus() this.requestMeasure() } private updatePlugins(update: ViewUpdate) { let prevSpecs = update.startState.facet(viewPlugin), specs = update.state.facet(viewPlugin) if (prevSpecs != specs) { let newPlugins = [] for (let spec of specs) { let found = prevSpecs.indexOf(spec) if (found < 0) { newPlugins.push(new PluginInstance(spec)) } else { let plugin = this.plugins[found] plugin.mustUpdate = update newPlugins.push(plugin) } } for (let plugin of this.plugins) if (plugin.mustUpdate != update) plugin.destroy(this) this.plugins = newPlugins this.pluginMap.clear() } else { for (let p of this.plugins) p.mustUpdate = update } for (let i = 0; i < this.plugins.length; i++) this.plugins[i].update(this) if (prevSpecs != specs) this.inputState.ensureHandlers(this.plugins) } private docViewUpdate() { for (let plugin of this.plugins) { let val = plugin.value if (val && val.docViewUpdate) { try { val.docViewUpdate(this) } catch(e) { logException(this.state, e, "doc view update listener") } } } } /// @internal measure(flush = true) { if (this.destroyed) return if (this.measureScheduled > -1) this.win.cancelAnimationFrame(this.measureScheduled) if (this.observer.delayedAndroidKey) { this.measureScheduled = -1 this.requestMeasure() return } this.measureScheduled = 0 // Prevent requestMeasure calls from scheduling another animation frame if (flush) this.observer.forceFlush() let updated: ViewUpdate | null = null let sDOM = this.scrollDOM, scrollTop = sDOM.scrollTop * this.scaleY let {scrollAnchorPos, scrollAnchorHeight} = this.viewState if (Math.abs(scrollTop - this.viewState.scrollTop) > 1) scrollAnchorHeight = -1 this.viewState.scrollAnchorHeight = -1 try { for (let i = 0;; i++) { if (scrollAnchorHeight < 0) { if (isScrolledToBottom(sDOM)) { scrollAnchorPos = -1 scrollAnchorHeight = this.viewState.heightMap.height } else { let block = this.viewState.scrollAnchorAt(scrollTop) scrollAnchorPos = block.from scrollAnchorHeight = block.top } } this.updateState = UpdateState.Measuring let changed = this.viewState.measure(this) if (!changed && !this.measureRequests.length && this.viewState.scrollTarget == null) break if (i > 5) { console.warn(this.measureRequests.length ? "Measure loop restarted more than 5 times" : "Viewport failed to stabilize") break } let measuring: MeasureRequest[] = [] // Only run measure requests in this cycle when the viewport didn't change if (!(changed & UpdateFlag.Viewport)) [this.measureRequests, measuring] = [measuring, this.measureRequests] let measured = measuring.map(m => { try { return m.read(this) } catch(e) { logException(this.state, e); return BadMeasure } }) let update = ViewUpdate.create(this, this.state, []), redrawn = false update.flags |= changed if (!updated) updated = update else updated.flags |= changed this.updateState = UpdateState.Updating if (!update.empty) { this.updatePlugins(update) this.inputState.update(update) this.updateAttrs() redrawn = this.docView.update(update) if (redrawn) this.docViewUpdate() } for (let i = 0; i < measuring.length; i++) if (measured[i] != BadMeasure) { try { let m = measuring[i] if (m.write) m.write(measured[i], this) } catch(e) { logException(this.state, e) } } if (redrawn) this.docView.updateSelection(true) if (!update.viewportChanged && this.measureRequests.length == 0) { if (this.viewState.editorHeight) { if (this.viewState.scrollTarget) { this.docView.scrollIntoView(this.viewState.scrollTarget) this.viewState.scrollTarget = null scrollAnchorHeight = -1 continue } else { let newAnchorHeight = scrollAnchorPos < 0 ? this.viewState.heightMap.height : this.viewState.lineBlockAt(scrollAnchorPos).top let diff = newAnchorHeight - scrollAnchorHeight if (diff > 1 || diff < -1) { scrollTop = scrollTop + diff sDOM.scrollTop = scrollTop / this.scaleY scrollAnchorHeight = -1 continue } } } break } } } finally { this.updateState = UpdateState.Idle this.measureScheduled = -1 } if (updated && !updated.empty) for (let listener of this.state.facet(updateListener)) listener(updated) } /// Get the CSS classes for the currently active editor themes. get themeClasses() { return baseThemeID + " " + (this.state.facet(darkTheme) ? baseDarkID : baseLightID) + " " + this.state.facet(theme) } private updateAttrs() { let editorAttrs = attrsFromFacet(this, editorAttributes, { class: "cm-editor" + (this.hasFocus ? " cm-focused " : " ") + this.themeClasses }) let contentAttrs: Attrs = { spellcheck: "false", autocorrect: "off", autocapitalize: "off", translate: "no", contenteditable: !this.state.facet(editable) ? "false" : "true", class: "cm-content", style: `${browser.tabSize}: ${this.state.tabSize}`, role: "textbox", "aria-multiline": "true" } if (this.state.readOnly) contentAttrs["aria-readonly"] = "true" attrsFromFacet(this, contentAttributes, contentAttrs) let changed = this.observer.ignore(() => { let changedContent = updateAttrs(this.contentDOM, this.contentAttrs, contentAttrs) let changedEditor = updateAttrs(this.dom, this.editorAttrs, editorAttrs) return changedContent || changedEditor }) this.editorAttrs = editorAttrs this.contentAttrs = contentAttrs return changed } private showAnnouncements(trs: readonly Transaction[]) { let first = true for (let tr of trs) for (let effect of tr.effects) if (effect.is(EditorView.announce)) { if (first) this.announceDOM.textContent = "" first = false let div = this.announceDOM.appendChild(document.createElement("div")) div.textContent = effect.value } } private mountStyles() { this.styleModules = this.state.facet(styleModule) let nonce = this.state.facet(EditorView.cspNonce) StyleModule.mount(this.root, this.styleModules.concat(baseTheme).reverse(), nonce ? {nonce} : undefined) } private readMeasured() { if (this.updateState == UpdateState.Updating) throw new Error("Reading the editor layout isn't allowed during an update") if (this.updateState == UpdateState.Idle && this.measureScheduled > -1) this.measure(false) } /// Schedule a layout measurement, optionally providing callbacks to /// do custom DOM measuring followed by a DOM write phase. Using /// this is preferable reading DOM layout directly from, for /// example, an event handler, because it'll make sure measuring and /// drawing done by other components is synchronized, avoiding /// unnecessary DOM layout computations. requestMeasure(request?: MeasureRequest) { if (this.measureScheduled < 0) this.measureScheduled = this.win.requestAnimationFrame(() => this.measure()) if (request) { if (this.measureRequests.indexOf(request) > -1) return if (request.key != null) for (let i = 0; i < this.measureRequests.length; i++) { if (this.measureRequests[i].key === request.key) { this.measureRequests[i] = request return } } this.measureRequests.push(request) } } /// Get the value of a specific plugin, if present. Note that /// plugins that crash can be dropped from a view, so even when you /// know you registered a given plugin, it is recommended to check /// the return value of this method. plugin(plugin: ViewPlugin): T | null { let known = this.pluginMap.get(plugin) if (known === undefined || known && known.spec != plugin) this.pluginMap.set(plugin, known = this.plugins.find(p => p.spec == plugin) || null) return known && known.update(this).value as T } /// The top position of the document, in screen coordinates. This /// may be negative when the editor is scrolled down. Points /// directly to the top of the first line, not above the padding. get documentTop() { return this.contentDOM.getBoundingClientRect().top + this.viewState.paddingTop } /// Reports the padding above and below the document. get documentPadding() { return {top: this.viewState.paddingTop, bottom: this.viewState.paddingBottom} } /// If the editor is transformed with CSS, this provides the scale /// along the X axis. Otherwise, it will just be 1. Note that /// transforms other than translation and scaling are not supported. get scaleX() { return this.viewState.scaleX } /// Provide the CSS transformed scale along the Y axis. get scaleY() { return this.viewState.scaleY } /// Find the text line or block widget at the given vertical /// position (which is interpreted as relative to the [top of the /// document](#view.EditorView.documentTop)). elementAtHeight(height: number) { this.readMeasured() return this.viewState.elementAtHeight(height) } /// Find the line block (see /// [`lineBlockAt`](#view.EditorView.lineBlockAt) at the given /// height, again interpreted relative to the [top of the /// document](#view.EditorView.documentTop). lineBlockAtHeight(height: number): BlockInfo { this.readMeasured() return this.viewState.lineBlockAtHeight(height) } /// Get the extent and vertical position of all [line /// blocks](#view.EditorView.lineBlockAt) in the viewport. Positions /// are relative to the [top of the /// document](#view.EditorView.documentTop); get viewportLineBlocks() { return this.viewState.viewportLines } /// Find the line block around the given document position. A line /// block is a range delimited on both sides by either a /// non-[hidden](#view.Decoration^replace) line breaks, or the /// start/end of the document. It will usually just hold a line of /// text, but may be broken into multiple textblocks by block /// widgets. lineBlockAt(pos: number): BlockInfo { return this.viewState.lineBlockAt(pos) } /// The editor's total content height. get contentHeight() { return this.viewState.contentHeight } /// Move a cursor position by [grapheme /// cluster](#state.findClusterBreak). `forward` determines whether /// the motion is away from the line start, or towards it. In /// bidirectional text, the line is traversed in visual order, using /// the editor's [text direction](#view.EditorView.textDirection). /// When the start position was the last one on the line, the /// returned position will be across the line break. If there is no /// further line, the original position is returned. /// /// By default, this method moves over a single cluster. The /// optional `by` argument can be used to move across more. It will /// be called with the first cluster as argument, and should return /// a predicate that determines, for each subsequent cluster, /// whether it should also be moved over. moveByChar(start: SelectionRange, forward: boolean, by?: (initial: string) => (next: string) => boolean) { return skipAtoms(this, start, moveByChar(this, start, forward, by)) } /// Move a cursor position across the next group of either /// [letters](#state.EditorState.charCategorizer) or non-letter /// non-whitespace characters. moveByGroup(start: SelectionRange, forward: boolean) { return skipAtoms(this, start, moveByChar(this, start, forward, initial => byGroup(this, start.head, initial))) } /// Get the cursor position visually at the start or end of a line. /// Note that this may differ from the _logical_ position at its /// start or end (which is simply at `line.from`/`line.to`) if text /// at the start or end goes against the line's base text direction. visualLineSide(line: Line, end: boolean) { let order = this.bidiSpans(line), dir = this.textDirectionAt(line.from) let span = order[end ? order.length - 1 : 0] return EditorSelection.cursor(span.side(end, dir) + line.from, span.forward(!end, dir) ? 1 : -1) } /// Move to the next line boundary in the given direction. If /// `includeWrap` is true, line wrapping is on, and there is a /// further wrap point on the current line, the wrap point will be /// returned. Otherwise this function will return the start or end /// of the line. moveToLineBoundary(start: SelectionRange, forward: boolean, includeWrap = true) { return moveToLineBoundary(this, start, forward, includeWrap) } /// Move a cursor position vertically. When `distance` isn't given, /// it defaults to moving to the next line (including wrapped /// lines). Otherwise, `distance` should provide a positive distance /// in pixels. /// /// When `start` has a /// [`goalColumn`](#state.SelectionRange.goalColumn), the vertical /// motion will use that as a target horizontal position. Otherwise, /// the cursor's own horizontal position is used. The returned /// cursor will have its goal column set to whichever column was /// used. moveVertically(start: SelectionRange, forward: boolean, distance?: number) { return skipAtoms(this, start, moveVertically(this, start, forward, distance)) } /// Find the DOM parent node and offset (child offset if `node` is /// an element, character offset when it is a text node) at the /// given document position. /// /// Note that for positions that aren't currently in /// `visibleRanges`, the resulting DOM position isn't necessarily /// meaningful (it may just point before or after a placeholder /// element). domAtPos(pos: number): {node: Node, offset: number} { return this.docView.domAtPos(pos) } /// Find the document position at the given DOM node. Can be useful /// for associating positions with DOM events. Will raise an error /// when `node` isn't part of the editor content. posAtDOM(node: Node, offset: number = 0) { return this.docView.posFromDOM(node, offset) } /// Get the document position at the given screen coordinates. For /// positions not covered by the visible viewport's DOM structure, /// this will return null, unless `false` is passed as second /// argument, in which case it'll return an estimated position that /// would be near the coordinates if it were rendered. posAtCoords(coords: {x: number, y: number}, precise: false): number posAtCoords(coords: {x: number, y: number}): number | null posAtCoords(coords: {x: number, y: number}, precise = true): number | null { this.readMeasured() return posAtCoords(this, coords, precise) } /// Get the screen coordinates at the given document position. /// `side` determines whether the coordinates are based on the /// element before (-1) or after (1) the position (if no element is /// available on the given side, the method will transparently use /// another strategy to get reasonable coordinates). coordsAtPos(pos: number, side: -1 | 1 = 1): Rect | null { this.readMeasured() let rect = this.docView.coordsAt(pos, side) if (!rect || rect.left == rect.right) return rect let line = this.state.doc.lineAt(pos), order = this.bidiSpans(line) let span = order[BidiSpan.find(order, pos - line.from, -1, side)] return flattenRect(rect, (span.dir == Direction.LTR) == (side > 0)) } /// Return the rectangle around a given character. If `pos` does not /// point in front of a character that is in the viewport and /// rendered (i.e. not replaced, not a line break), this will return /// null. For space characters that are a line wrap point, this will /// return the position before the line break. coordsForChar(pos: number): Rect | null { this.readMeasured() return this.docView.coordsForChar(pos) } /// The default width of a character in the editor. May not /// accurately reflect the width of all characters (given variable /// width fonts or styling of invididual ranges). get defaultCharacterWidth() { return this.viewState.heightOracle.charWidth } /// The default height of a line in the editor. May not be accurate /// for all lines. get defaultLineHeight() { return this.viewState.heightOracle.lineHeight } /// The text direction /// ([`direction`](https://developer.mozilla.org/en-US/docs/Web/CSS/direction) /// CSS property) of the editor's content element. get textDirection(): Direction { return this.viewState.defaultTextDirection } /// Find the text direction of the block at the given position, as /// assigned by CSS. If /// [`perLineTextDirection`](#view.EditorView^perLineTextDirection) /// isn't enabled, or the given position is outside of the viewport, /// this will always return the same as /// [`textDirection`](#view.EditorView.textDirection). Note that /// this may trigger a DOM layout. textDirectionAt(pos: number) { let perLine = this.state.facet(perLineTextDirection) if (!perLine || pos < this.viewport.from || pos > this.viewport.to) return this.textDirection this.readMeasured() return this.docView.textDirectionAt(pos) } /// Whether this editor [wraps lines](#view.EditorView.lineWrapping) /// (as determined by the /// [`white-space`](https://developer.mozilla.org/en-US/docs/Web/CSS/white-space) /// CSS property of its content element). get lineWrapping(): boolean { return this.viewState.heightOracle.lineWrapping } /// Returns the bidirectional text structure of the given line /// (which should be in the current document) as an array of span /// objects. The order of these spans matches the [text /// direction](#view.EditorView.textDirection)—if that is /// left-to-right, the leftmost spans come first, otherwise the /// rightmost spans come first. bidiSpans(line: Line) { if (line.length > MaxBidiLine) return trivialOrder(line.length) let dir = this.textDirectionAt(line.from), isolates: readonly Isolate[] | undefined for (let entry of this.bidiCache) { if (entry.from == line.from && entry.dir == dir && (entry.fresh || isolatesEq(entry.isolates, isolates = getIsolatedRanges(this, line)))) return entry.order } if (!isolates) isolates = getIsolatedRanges(this, line) let order = computeOrder(line.text, dir, isolates) this.bidiCache.push(new CachedOrder(line.from, line.to, dir, isolates, true, order)) return order } /// Check whether the editor has focus. get hasFocus(): boolean { // Safari return false for hasFocus when the context menu is open // or closing, which leads us to ignore selection changes from the // context menu because it looks like the editor isn't focused. // This kludges around that. return (this.dom.ownerDocument.hasFocus() || browser.safari && this.inputState?.lastContextMenu > Date.now() - 3e4) && this.root.activeElement == this.contentDOM } /// Put focus on the editor. focus() { this.observer.ignore(() => { focusPreventScroll(this.contentDOM) this.docView.updateSelection() }) } /// Update the [root](##view.EditorViewConfig.root) in which the editor lives. This is only /// necessary when moving the editor's existing DOM to a new window or shadow root. setRoot(root: Document | ShadowRoot) { if (this._root != root) { this._root = root this.observer.setWindow((root.nodeType == 9 ? root as Document : root.ownerDocument!).defaultView || window) this.mountStyles() } } /// Clean up this editor view, removing its element from the /// document, unregistering event handlers, and notifying /// plugins. The view instance can no longer be used after /// calling this. destroy() { for (let plugin of this.plugins) plugin.destroy(this) this.plugins = [] this.inputState.destroy() this.docView.destroy() this.dom.remove() this.observer.destroy() if (this.measureScheduled > -1) this.win.cancelAnimationFrame(this.measureScheduled) this.destroyed = true } /// Returns an effect that can be /// [added](#state.TransactionSpec.effects) to a transaction to /// cause it to scroll the given position or range into view. static scrollIntoView(pos: number | SelectionRange, options: { /// By default (`"nearest"`) the position will be vertically /// scrolled only the minimal amount required to move the given /// position into view. You can set this to `"start"` to move it /// to the top of the view, `"end"` to move it to the bottom, or /// `"center"` to move it to the center. y?: ScrollStrategy, /// Effect similar to /// [`y`](#view.EditorView^scrollIntoView^options.y), but for the /// horizontal scroll position. x?: ScrollStrategy, /// Extra vertical distance to add when moving something into /// view. Not used with the `"center"` strategy. Defaults to 5. /// Must be less than the height of the editor. yMargin?: number, /// Extra horizontal distance to add. Not used with the `"center"` /// strategy. Defaults to 5. Must be less than the width of the /// editor. xMargin?: number, } = {}): StateEffect { return scrollIntoView.of(new ScrollTarget(typeof pos == "number" ? EditorSelection.cursor(pos) : pos, options.y, options.x, options.yMargin, options.xMargin)) } /// Return an effect that resets the editor to its current (at the /// time this method was called) scroll position. Note that this /// only affects the editor's own scrollable element, not parents. /// See also /// [`EditorViewConfig.scrollTo`](#view.EditorViewConfig.scrollTo). /// /// The effect should be used with a document identical to the one /// it was created for. Failing to do so is not an error, but may /// not scroll to the expected position. You can /// [map](#state.StateEffect.map) the effect to account for changes. scrollSnapshot() { let {scrollTop, scrollLeft} = this.scrollDOM let ref = this.viewState.scrollAnchorAt(scrollTop) return scrollIntoView.of(new ScrollTarget(EditorSelection.cursor(ref.from), "start", "start", ref.top - scrollTop, scrollLeft, true)) } /// Facet to add a [style /// module](https://github.com/marijnh/style-mod#documentation) to /// an editor view. The view will ensure that the module is /// mounted in its [document /// root](#view.EditorView.constructor^config.root). static styleModule = styleModule /// Returns an extension that can be used to add DOM event handlers. /// The value should be an object mapping event names to handler /// functions. For any given event, such functions are ordered by /// extension precedence, and the first handler to return true will /// be assumed to have handled that event, and no other handlers or /// built-in behavior will be activated for it. These are registered /// on the [content element](#view.EditorView.contentDOM), except /// for `scroll` handlers, which will be called any time the /// editor's [scroll element](#view.EditorView.scrollDOM) or one of /// its parent nodes is scrolled. static domEventHandlers(handlers: DOMEventHandlers): Extension { return ViewPlugin.define(() => ({}), {eventHandlers: handlers}) } /// Create an extension that registers DOM event observers. Contrary /// to event [handlers](#view.EditorView^domEventHandlers), /// observers can't be prevented from running by a higher-precedence /// handler returning true. They also don't prevent other handlers /// and observers from running when they return true, and should not /// call `preventDefault`. static domEventObservers(observers: DOMEventHandlers): Extension { return ViewPlugin.define(() => ({}), {eventObservers: observers}) } /// An input handler can override the way changes to the editable /// DOM content are handled. Handlers are passed the document /// positions between which the change was found, and the new /// content. When one returns true, no further input handlers are /// called and the default behavior is prevented. /// /// The `insert` argument can be used to get the default transaction /// that would be applied for this input. This can be useful when /// dispatching the custom behavior as a separate transaction. static inputHandler = inputHandler /// Scroll handlers can override how things are scrolled into view. /// If they return `true`, no further handling happens for the /// scrolling. If they return false, the default scroll behavior is /// applied. Scroll handlers should never initiate editor updates. static scrollHandler = scrollHandler /// This facet can be used to provide functions that create effects /// to be dispatched when the editor's focus state changes. static focusChangeEffect = focusChangeEffect /// By default, the editor assumes all its content has the same /// [text direction](#view.Direction). Configure this with a `true` /// value to make it read the text direction of every (rendered) /// line separately. static perLineTextDirection = perLineTextDirection /// Allows you to provide a function that should be called when the /// library catches an exception from an extension (mostly from view /// plugins, but may be used by other extensions to route exceptions /// from user-code-provided callbacks). This is mostly useful for /// debugging and logging. See [`logException`](#view.logException). static exceptionSink = exceptionSink /// A facet that can be used to register a function to be called /// every time the view updates. static updateListener = updateListener /// Facet that controls whether the editor content DOM is editable. /// When its highest-precedence value is `false`, the element will /// not have its `contenteditable` attribute set. (Note that this /// doesn't affect API calls that change the editor content, even /// when those are bound to keys or buttons. See the /// [`readOnly`](#state.EditorState.readOnly) facet for that.) static editable = editable /// Allows you to influence the way mouse selection happens. The /// functions in this facet will be called for a `mousedown` event /// on the editor, and can return an object that overrides the way a /// selection is computed from that mouse click or drag. static mouseSelectionStyle = mouseSelectionStyle /// Facet used to configure whether a given selection drag event /// should move or copy the selection. The given predicate will be /// called with the `mousedown` event, and can return `true` when /// the drag should move the content. static dragMovesSelection = dragMovesSelection /// Facet used to configure whether a given selecting click adds a /// new range to the existing selection or replaces it entirely. The /// default behavior is to check `event.metaKey` on macOS, and /// `event.ctrlKey` elsewhere. static clickAddsSelectionRange = clickAddsSelectionRange /// A facet that determines which [decorations](#view.Decoration) /// are shown in the view. Decorations can be provided in two /// ways—directly, or via a function that takes an editor view. /// /// Only decoration sets provided directly are allowed to influence /// the editor's vertical layout structure. The ones provided as /// functions are called _after_ the new viewport has been computed, /// and thus **must not** introduce block widgets or replacing /// decorations that cover line breaks. /// /// If you want decorated ranges to behave like atomic units for /// cursor motion and deletion purposes, also provide the range set /// containing the decorations to /// [`EditorView.atomicRanges`](#view.EditorView^atomicRanges). static decorations = decorations /// Facet that works much like /// [`decorations`](#view.EditorView^decorations), but puts its /// inputs at the very bottom of the precedence stack, meaning mark /// decorations provided here will only be split by other, partially /// overlapping \`outerDecorations\` ranges, and wrap around all /// regular decorations. Use this for mark elements that should, as /// much as possible, remain in one piece. static outerDecorations = outerDecorations /// Used to provide ranges that should be treated as atoms as far as /// cursor motion is concerned. This causes methods like /// [`moveByChar`](#view.EditorView.moveByChar) and /// [`moveVertically`](#view.EditorView.moveVertically) (and the /// commands built on top of them) to skip across such regions when /// a selection endpoint would enter them. This does _not_ prevent /// direct programmatic [selection /// updates](#state.TransactionSpec.selection) from moving into such /// regions. static atomicRanges = atomicRanges /// When range decorations add a `unicode-bidi: isolate` style, they /// should also include a /// [`bidiIsolate`](#view.MarkDecorationSpec.bidiIsolate) property /// in their decoration spec, and be exposed through this facet, so /// that the editor can compute the proper text order. (Other values /// for `unicode-bidi`, except of course `normal`, are not /// supported.) static bidiIsolatedRanges = bidiIsolatedRanges /// Facet that allows extensions to provide additional scroll /// margins (space around the sides of the scrolling element that /// should be considered invisible). This can be useful when the /// plugin introduces elements that cover part of that element (for /// example a horizontally fixed gutter). static scrollMargins = scrollMargins /// Create a theme extension. The first argument can be a /// [`style-mod`](https://github.com/marijnh/style-mod#documentation) /// style spec providing the styles for the theme. These will be /// prefixed with a generated class for the style. /// /// Because the selectors will be prefixed with a scope class, rule /// that directly match the editor's [wrapper /// element](#view.EditorView.dom)—to which the scope class will be /// added—need to be explicitly differentiated by adding an `&` to /// the selector for that element—for example /// `&.cm-focused`. /// /// When `dark` is set to true, the theme will be marked as dark, /// which will cause the `&dark` rules from [base /// themes](#view.EditorView^baseTheme) to be used (as opposed to /// `&light` when a light theme is active). static theme(spec: {[selector: string]: StyleSpec}, options?: {dark?: boolean}): Extension { let prefix = StyleModule.newName() let result = [theme.of(prefix), styleModule.of(buildTheme(`.${prefix}`, spec))] if (options && options.dark) result.push(darkTheme.of(true)) return result } /// This facet records whether a dark theme is active. The extension /// returned by [`theme`](#view.EditorView^theme) automatically /// includes an instance of this when the `dark` option is set to /// true. static darkTheme = darkTheme /// Create an extension that adds styles to the base theme. Like /// with [`theme`](#view.EditorView^theme), use `&` to indicate the /// place of the editor wrapper element when directly targeting /// that. You can also use `&dark` or `&light` instead to only /// target editors with a dark or light theme. static baseTheme(spec: {[selector: string]: StyleSpec}): Extension { return Prec.lowest(styleModule.of(buildTheme("." + baseThemeID, spec, lightDarkIDs))) } /// Provides a Content Security Policy nonce to use when creating /// the style sheets for the editor. Holds the empty string when no /// nonce has been provided. static cspNonce = Facet.define({combine: values => values.length ? values[0] : ""}) /// Facet that provides additional DOM attributes for the editor's /// editable DOM element. static contentAttributes = contentAttributes /// Facet that provides DOM attributes for the editor's outer /// element. static editorAttributes = editorAttributes /// An extension that enables line wrapping in the editor (by /// setting CSS `white-space` to `pre-wrap` in the content). static lineWrapping = EditorView.contentAttributes.of({"class": "cm-lineWrapping"}) /// State effect used to include screen reader announcements in a /// transaction. These will be added to the DOM in a visually hidden /// element with `aria-live="polite"` set, and should be used to /// describe effects that are visually obvious but may not be /// noticed by screen reader users (such as moving to the next /// search match). static announce = StateEffect.define() /// Retrieve an editor view instance from the view's DOM /// representation. static findFromDOM(dom: HTMLElement): EditorView | null { let content = dom.querySelector(".cm-content") let cView = content && ContentView.get(content) || ContentView.get(dom) return (cView?.rootView as DocView)?.view || null } } /// Helper type that maps event names to event object types, or the /// `any` type for unknown events. export interface DOMEventMap extends HTMLElementEventMap { [other: string]: any } /// Event handlers are specified with objects like this. For event /// types known by TypeScript, this will infer the event argument type /// to hold the appropriate event object type. For unknown events, it /// is inferred to `any`, and should be explicitly set if you want type /// checking. export type DOMEventHandlers = { [event in keyof DOMEventMap]?: (this: This, event: DOMEventMap[event], view: EditorView) => boolean | void } // Maximum line length for which we compute accurate bidi info const MaxBidiLine = 4096 const BadMeasure = {} class CachedOrder { constructor( readonly from: number, readonly to: number, readonly dir: Direction, readonly isolates: readonly Isolate[], readonly fresh: boolean, readonly order: readonly BidiSpan[] ) {} static update(cache: CachedOrder[], changes: ChangeDesc) { if (changes.empty && !cache.some(c => c.fresh)) return cache let result = [], lastDir = cache.length ? cache[cache.length - 1].dir : Direction.LTR for (let i = Math.max(0, cache.length - 10); i < cache.length; i++) { let entry = cache[i] if (entry.dir == lastDir && !changes.touchesRange(entry.from, entry.to)) result.push(new CachedOrder(changes.mapPos(entry.from, 1), changes.mapPos(entry.to, -1), entry.dir, entry.isolates, false, entry.order)) } return result } } function attrsFromFacet(view: EditorView, facet: Facet, base: Attrs) { for (let sources = view.state.facet(facet), i = sources.length - 1; i >= 0; i--) { let source = sources[i], value = typeof source == "function" ? source(view) : source if (value) combineAttrs(value, base) } return base } view-6.26.3/src/extension.ts000066400000000000000000000375521460616253600157620ustar00rootroot00000000000000import {EditorState, Transaction, ChangeSet, ChangeDesc, Facet, Line, StateEffect, Extension, SelectionRange, RangeSet, EditorSelection} from "@codemirror/state" import {StyleModule} from "style-mod" import {DecorationSet, Decoration} from "./decoration" import {EditorView, DOMEventHandlers} from "./editorview" import {Attrs} from "./attributes" import {Isolate, autoDirection} from "./bidi" import {Rect, ScrollStrategy} from "./dom" import {MakeSelectionStyle} from "./input" /// Command functions are used in key bindings and other types of user /// actions. Given an editor view, they check whether their effect can /// apply to the editor, and if it can, perform it as a side effect /// (which usually means [dispatching](#view.EditorView.dispatch) a /// transaction) and return `true`. export type Command = (target: EditorView) => boolean export const clickAddsSelectionRange = Facet.define<(event: MouseEvent) => boolean>() export const dragMovesSelection = Facet.define<(event: MouseEvent) => boolean>() export const mouseSelectionStyle = Facet.define() export const exceptionSink = Facet.define<(exception: any) => void>() export const updateListener = Facet.define<(update: ViewUpdate) => void>() export const inputHandler = Facet.define<(view: EditorView, from: number, to: number, text: string, insert: () => Transaction) => boolean>() export const focusChangeEffect = Facet.define<(state: EditorState, focusing: boolean) => StateEffect | null>() export const perLineTextDirection = Facet.define({ combine: values => values.some(x => x) }) export const nativeSelectionHidden = Facet.define({ combine: values => values.some(x => x) }) export const scrollHandler = Facet.define<( view: EditorView, range: SelectionRange, options: {x: ScrollStrategy, y: ScrollStrategy, xMargin: number, yMargin: number} ) => boolean>() export class ScrollTarget { constructor( readonly range: SelectionRange, readonly y: ScrollStrategy = "nearest", readonly x: ScrollStrategy = "nearest", readonly yMargin: number = 5, readonly xMargin: number = 5, // This data structure is abused to also store precise scroll // snapshots, instead of a `scrollIntoView` request. When this // flag is `true`, `range` points at a position in the reference // line, `yMargin` holds the difference between the top of that // line and the top of the editor, and `xMargin` holds the // editor's `scrollLeft`. readonly isSnapshot = false ) {} map(changes: ChangeDesc) { return changes.empty ? this : new ScrollTarget(this.range.map(changes), this.y, this.x, this.yMargin, this.xMargin, this.isSnapshot) } clip(state: EditorState) { return this.range.to <= state.doc.length ? this : new ScrollTarget(EditorSelection.cursor(state.doc.length), this.y, this.x, this.yMargin, this.xMargin, this.isSnapshot) } } export const scrollIntoView = StateEffect.define({map: (t, ch) => t.map(ch)}) /// Log or report an unhandled exception in client code. Should /// probably only be used by extension code that allows client code to /// provide functions, and calls those functions in a context where an /// exception can't be propagated to calling code in a reasonable way /// (for example when in an event handler). /// /// Either calls a handler registered with /// [`EditorView.exceptionSink`](#view.EditorView^exceptionSink), /// `window.onerror`, if defined, or `console.error` (in which case /// it'll pass `context`, when given, as first argument). export function logException(state: EditorState, exception: any, context?: string) { let handler = state.facet(exceptionSink) if (handler.length) handler[0](exception) else if (window.onerror) window.onerror(String(exception), context, undefined, undefined, exception) else if (context) console.error(context + ":", exception) else console.error(exception) } export const editable = Facet.define({combine: values => values.length ? values[0] : true }) /// This is the interface plugin objects conform to. export interface PluginValue extends Object { /// Notifies the plugin of an update that happened in the view. This /// is called _before_ the view updates its own DOM. It is /// responsible for updating the plugin's internal state (including /// any state that may be read by plugin fields) and _writing_ to /// the DOM for the changes in the update. To avoid unnecessary /// layout recomputations, it should _not_ read the DOM layout—use /// [`requestMeasure`](#view.EditorView.requestMeasure) to schedule /// your code in a DOM reading phase if you need to. update?(update: ViewUpdate): void /// Called when the document view is updated (due to content, /// decoration, or viewport changes). Should not try to immediately /// start another view update. Often useful for calling /// [`requestMeasure`](#view.EditorView.requestMeasure). docViewUpdate?(view: EditorView): void /// Called when the plugin is no longer going to be used. Should /// revert any changes the plugin made to the DOM. destroy?(): void } let nextPluginID = 0 export const viewPlugin = Facet.define>() /// Provides additional information when defining a [view /// plugin](#view.ViewPlugin). export interface PluginSpec { /// Register the given [event /// handlers](#view.EditorView^domEventHandlers) for the plugin. /// When called, these will have their `this` bound to the plugin /// value. eventHandlers?: DOMEventHandlers, /// Registers [event observers](#view.EditorView^domEventObservers) /// for the plugin. Will, when called, have their `this` bound to /// the plugin value. eventObservers?: DOMEventHandlers, /// Specify that the plugin provides additional extensions when /// added to an editor configuration. provide?: (plugin: ViewPlugin) => Extension /// Allow the plugin to provide decorations. When given, this should /// be a function that take the plugin value and return a /// [decoration set](#view.DecorationSet). See also the caveat about /// [layout-changing decorations](#view.EditorView^decorations) that /// depend on the view. decorations?: (value: V) => DecorationSet } /// View plugins associate stateful values with a view. They can /// influence the way the content is drawn, and are notified of things /// that happen in the view. export class ViewPlugin { /// Instances of this class act as extensions. extension: Extension private constructor( /// @internal readonly id: number, /// @internal readonly create: (view: EditorView) => V, /// @internal readonly domEventHandlers: DOMEventHandlers | undefined, /// @internal readonly domEventObservers: DOMEventHandlers | undefined, buildExtensions: (plugin: ViewPlugin) => Extension ) { this.extension = buildExtensions(this) } /// Define a plugin from a constructor function that creates the /// plugin's value, given an editor view. static define(create: (view: EditorView) => V, spec?: PluginSpec) { const {eventHandlers, eventObservers, provide, decorations: deco} = spec || {} return new ViewPlugin(nextPluginID++, create, eventHandlers, eventObservers, plugin => { let ext = [viewPlugin.of(plugin)] if (deco) ext.push(decorations.of(view => { let pluginInst = view.plugin(plugin) return pluginInst ? deco(pluginInst) : Decoration.none })) if (provide) ext.push(provide(plugin)) return ext }) } /// Create a plugin for a class whose constructor takes a single /// editor view as argument. static fromClass(cls: {new (view: EditorView): V}, spec?: PluginSpec) { return ViewPlugin.define(view => new cls(view), spec) } } export class PluginInstance { // When starting an update, all plugins have this field set to the // update object, indicating they need to be updated. When finished // updating, it is set to `false`. Retrieving a plugin that needs to // be updated with `view.plugin` forces an eager update. mustUpdate: ViewUpdate | null = null // This is null when the plugin is initially created, but // initialized on the first update. value: PluginValue | null = null constructor(public spec: ViewPlugin | null) {} update(view: EditorView) { if (!this.value) { if (this.spec) { try { this.value = this.spec.create(view) } catch (e) { logException(view.state, e, "CodeMirror plugin crashed") this.deactivate() } } } else if (this.mustUpdate) { let update = this.mustUpdate this.mustUpdate = null if (this.value.update) { try { this.value.update(update) } catch (e) { logException(update.state, e, "CodeMirror plugin crashed") if (this.value.destroy) try { this.value.destroy() } catch (_) {} this.deactivate() } } } return this } destroy(view: EditorView) { if (this.value?.destroy) { try { this.value.destroy() } catch (e) { logException(view.state, e, "CodeMirror plugin crashed") } } } deactivate() { this.spec = this.value = null } } export interface MeasureRequest { /// Called in a DOM read phase to gather information that requires /// DOM layout. Should _not_ mutate the document. read(view: EditorView): T /// Called in a DOM write phase to update the document. Should _not_ /// do anything that triggers DOM layout. write?(measure: T, view: EditorView): void /// When multiple requests with the same key are scheduled, only the /// last one will actually be run. key?: any } export type AttrSource = Attrs | ((view: EditorView) => Attrs | null) export const editorAttributes = Facet.define() export const contentAttributes = Facet.define() // Provide decorations export const decorations = Facet.define DecorationSet)>() export const outerDecorations = Facet.define DecorationSet)>() export const atomicRanges = Facet.define<(view: EditorView) => RangeSet>() export const bidiIsolatedRanges = Facet.define DecorationSet)>() export function getIsolatedRanges(view: EditorView, line: Line): readonly Isolate[] { let isolates = view.state.facet(bidiIsolatedRanges) if (!isolates.length) return isolates as any[] let sets = isolates.map(i => i instanceof Function ? i(view) : i) let result: Isolate[] = [] RangeSet.spans(sets, line.from, line.to, { point() {}, span(fromDoc, toDoc, active, open) { let from = fromDoc - line.from, to = toDoc - line.from let level = result for (let i = active.length - 1; i >= 0; i--, open--) { let direction = active[i].spec.bidiIsolate, update if (direction == null) direction = autoDirection(line.text, from, to) if (open > 0 && level.length && (update = level[level.length - 1]).to == from && update.direction == direction) { update.to = to level = update.inner as Isolate[] } else { let add = {from, to, direction, inner: []} level.push(add) level = add.inner } } } }) return result } export const scrollMargins = Facet.define<(view: EditorView) => Partial | null>() export function getScrollMargins(view: EditorView) { let left = 0, right = 0, top = 0, bottom = 0 for (let source of view.state.facet(scrollMargins)) { let m = source(view) if (m) { if (m.left != null) left = Math.max(left, m.left) if (m.right != null) right = Math.max(right, m.right) if (m.top != null) top = Math.max(top, m.top) if (m.bottom != null) bottom = Math.max(bottom, m.bottom) } } return {left, right, top, bottom} } export const styleModule = Facet.define() export const enum UpdateFlag { Focus = 1, Height = 2, Viewport = 4, Geometry = 8 } export class ChangedRange { constructor(readonly fromA: number, readonly toA: number, readonly fromB: number, readonly toB: number) {} join(other: ChangedRange): ChangedRange { return new ChangedRange(Math.min(this.fromA, other.fromA), Math.max(this.toA, other.toA), Math.min(this.fromB, other.fromB), Math.max(this.toB, other.toB)) } addToSet(set: ChangedRange[]): ChangedRange[] { let i = set.length, me: ChangedRange = this for (; i > 0; i--) { let range = set[i - 1] if (range.fromA > me.toA) continue if (range.toA < me.fromA) break me = me.join(range) set.splice(i - 1, 1) } set.splice(i, 0, me) return set } static extendWithRanges(diff: readonly ChangedRange[], ranges: number[]): readonly ChangedRange[] { if (ranges.length == 0) return diff let result: ChangedRange[] = [] for (let dI = 0, rI = 0, posA = 0, posB = 0;; dI++) { let next = dI == diff.length ? null : diff[dI], off = posA - posB let end = next ? next.fromB : 1e9 while (rI < ranges.length && ranges[rI] < end) { let from = ranges[rI], to = ranges[rI + 1] let fromB = Math.max(posB, from), toB = Math.min(end, to) if (fromB <= toB) new ChangedRange(fromB + off, toB + off, fromB, toB).addToSet(result) if (to > end) break else rI += 2 } if (!next) return result new ChangedRange(next.fromA, next.toA, next.fromB, next.toB).addToSet(result) posA = next.toA; posB = next.toB } } } /// View [plugins](#view.ViewPlugin) are given instances of this /// class, which describe what happened, whenever the view is updated. export class ViewUpdate { /// The changes made to the document by this update. readonly changes: ChangeSet /// The previous editor state. readonly startState: EditorState /// @internal flags = 0 /// @internal changedRanges: readonly ChangedRange[] private constructor( /// The editor view that the update is associated with. readonly view: EditorView, /// The new editor state. readonly state: EditorState, /// The transactions involved in the update. May be empty. readonly transactions: readonly Transaction[] ) { this.startState = view.state this.changes = ChangeSet.empty(this.startState.doc.length) for (let tr of transactions) this.changes = this.changes.compose(tr.changes) let changedRanges: ChangedRange[] = [] this.changes.iterChangedRanges((fromA, toA, fromB, toB) => changedRanges.push(new ChangedRange(fromA, toA, fromB, toB))) this.changedRanges = changedRanges } /// @internal static create(view: EditorView, state: EditorState, transactions: readonly Transaction[]) { return new ViewUpdate(view, state, transactions) } /// Tells you whether the [viewport](#view.EditorView.viewport) or /// [visible ranges](#view.EditorView.visibleRanges) changed in this /// update. get viewportChanged() { return (this.flags & UpdateFlag.Viewport) > 0 } /// Indicates whether the height of a block element in the editor /// changed in this update. get heightChanged() { return (this.flags & UpdateFlag.Height) > 0 } /// Returns true when the document was modified or the size of the /// editor, or elements within the editor, changed. get geometryChanged() { return this.docChanged || (this.flags & (UpdateFlag.Geometry | UpdateFlag.Height)) > 0 } /// True when this update indicates a focus change. get focusChanged() { return (this.flags & UpdateFlag.Focus) > 0 } /// Whether the document changed in this update. get docChanged() { return !this.changes.empty } /// Whether the selection was explicitly set in this update. get selectionSet() { return this.transactions.some(tr => tr.selection) } /// @internal get empty() { return this.flags == 0 && this.transactions.length == 0 } } view-6.26.3/src/gutter.ts000066400000000000000000000440641460616253600152540ustar00rootroot00000000000000import {combineConfig, MapMode, Facet, Extension, EditorState, RangeValue, RangeSet, RangeCursor} from "@codemirror/state" import {EditorView} from "./editorview" import {ViewPlugin, ViewUpdate} from "./extension" import {BlockType, WidgetType} from "./decoration" import {BlockInfo} from "./heightmap" import {Direction} from "./bidi" /// A gutter marker represents a bit of information attached to a line /// in a specific gutter. Your own custom markers have to extend this /// class. export abstract class GutterMarker extends RangeValue { /// @internal compare(other: GutterMarker) { return this == other || this.constructor == other.constructor && this.eq(other) } /// Compare this marker to another marker of the same type. eq(other: GutterMarker): boolean { return false } /// Render the DOM node for this marker, if any. toDOM?(view: EditorView): Node /// This property can be used to add CSS classes to the gutter /// element that contains this marker. elementClass!: string /// Called if the marker has a `toDOM` method and its representation /// was removed from a gutter. destroy(dom: Node) {} } GutterMarker.prototype.elementClass = "" GutterMarker.prototype.toDOM = undefined GutterMarker.prototype.mapMode = MapMode.TrackBefore GutterMarker.prototype.startSide = GutterMarker.prototype.endSide = -1 GutterMarker.prototype.point = true /// Facet used to add a class to all gutter elements for a given line. /// Markers given to this facet should _only_ define an /// [`elementclass`](#view.GutterMarker.elementClass), not a /// [`toDOM`](#view.GutterMarker.toDOM) (or the marker will appear /// in all gutters for the line). export const gutterLineClass = Facet.define>() type Handlers = {[event: string]: (view: EditorView, line: BlockInfo, event: Event) => boolean} interface GutterConfig { /// An extra CSS class to be added to the wrapper (`cm-gutter`) /// element. class?: string /// Controls whether empty gutter elements should be rendered. /// Defaults to false. renderEmptyElements?: boolean /// Retrieve a set of markers to use in this gutter. markers?: (view: EditorView) => (RangeSet | readonly RangeSet[]) /// Can be used to optionally add a single marker to every line. lineMarker?: (view: EditorView, line: BlockInfo, otherMarkers: readonly GutterMarker[]) => GutterMarker | null /// Associate markers with block widgets in the document. widgetMarker?: (view: EditorView, widget: WidgetType, block: BlockInfo) => GutterMarker | null /// If line or widget markers depend on additional state, and should /// be updated when that changes, pass a predicate here that checks /// whether a given view update might change the line markers. lineMarkerChange?: null | ((update: ViewUpdate) => boolean) /// Add a hidden spacer element that gives the gutter its base /// width. initialSpacer?: null | ((view: EditorView) => GutterMarker) /// Update the spacer element when the view is updated. updateSpacer?: null | ((spacer: GutterMarker, update: ViewUpdate) => GutterMarker) /// Supply event handlers for DOM events on this gutter. domEventHandlers?: Handlers, } const defaults = { class: "", renderEmptyElements: false, elementStyle: "", markers: () => RangeSet.empty, lineMarker: () => null, widgetMarker: () => null, lineMarkerChange: null, initialSpacer: null, updateSpacer: null, domEventHandlers: {} } const activeGutters = Facet.define>() /// Define an editor gutter. The order in which the gutters appear is /// determined by their extension priority. export function gutter(config: GutterConfig): Extension { return [gutters(), activeGutters.of({...defaults, ...config})] } const unfixGutters = Facet.define({ combine: values => values.some(x => x) }) /// The gutter-drawing plugin is automatically enabled when you add a /// gutter, but you can use this function to explicitly configure it. /// /// Unless `fixed` is explicitly set to `false`, the gutters are /// fixed, meaning they don't scroll along with the content /// horizontally (except on Internet Explorer, which doesn't support /// CSS [`position: /// sticky`](https://developer.mozilla.org/en-US/docs/Web/CSS/position#sticky)). export function gutters(config?: {fixed?: boolean}): Extension { let result: Extension[] = [ gutterView, ] if (config && config.fixed === false) result.push(unfixGutters.of(true)) return result } const gutterView = ViewPlugin.fromClass(class { gutters: SingleGutterView[] dom: HTMLElement fixed: boolean prevViewport: {from: number, to: number} constructor(readonly view: EditorView) { this.prevViewport = view.viewport this.dom = document.createElement("div") this.dom.className = "cm-gutters" this.dom.setAttribute("aria-hidden", "true") this.dom.style.minHeight = (this.view.contentHeight / this.view.scaleY) + "px" this.gutters = view.state.facet(activeGutters).map(conf => new SingleGutterView(view, conf)) for (let gutter of this.gutters) this.dom.appendChild(gutter.dom) this.fixed = !view.state.facet(unfixGutters) if (this.fixed) { // FIXME IE11 fallback, which doesn't support position: sticky, // by using position: relative + event handlers that realign the // gutter (or just force fixed=false on IE11?) this.dom.style.position = "sticky" } this.syncGutters(false) view.scrollDOM.insertBefore(this.dom, view.contentDOM) } update(update: ViewUpdate) { if (this.updateGutters(update)) { // Detach during sync when the viewport changed significantly // (such as during scrolling), since for large updates that is // faster. let vpA = this.prevViewport, vpB = update.view.viewport let vpOverlap = Math.min(vpA.to, vpB.to) - Math.max(vpA.from, vpB.from) this.syncGutters(vpOverlap < (vpB.to - vpB.from) * 0.8) } if (update.geometryChanged) { this.dom.style.minHeight = (this.view.contentHeight / this.view.scaleY) + "px" } if (this.view.state.facet(unfixGutters) != !this.fixed) { this.fixed = !this.fixed this.dom.style.position = this.fixed ? "sticky" : "" } this.prevViewport = update.view.viewport } syncGutters(detach: boolean) { let after = this.dom.nextSibling if (detach) this.dom.remove() let lineClasses = RangeSet.iter(this.view.state.facet(gutterLineClass), this.view.viewport.from) let classSet: GutterMarker[] = [] let contexts = this.gutters.map(gutter => new UpdateContext(gutter, this.view.viewport, -this.view.documentPadding.top)) for (let line of this.view.viewportLineBlocks) { if (classSet.length) classSet = [] if (Array.isArray(line.type)) { let first = true for (let b of line.type) { if (b.type == BlockType.Text && first) { advanceCursor(lineClasses, classSet, b.from) for (let cx of contexts) cx.line(this.view, b, classSet) first = false } else if (b.widget) { for (let cx of contexts) cx.widget(this.view, b) } } } else if (line.type == BlockType.Text) { advanceCursor(lineClasses, classSet, line.from) for (let cx of contexts) cx.line(this.view, line, classSet) } else if (line.widget) { for (let cx of contexts) cx.widget(this.view, line) } } for (let cx of contexts) cx.finish() if (detach) this.view.scrollDOM.insertBefore(this.dom, after) } updateGutters(update: ViewUpdate) { let prev = update.startState.facet(activeGutters), cur = update.state.facet(activeGutters) let change = update.docChanged || update.heightChanged || update.viewportChanged || !RangeSet.eq(update.startState.facet(gutterLineClass), update.state.facet(gutterLineClass), update.view.viewport.from, update.view.viewport.to) if (prev == cur) { for (let gutter of this.gutters) if (gutter.update(update)) change = true } else { change = true let gutters = [] for (let conf of cur) { let known = prev.indexOf(conf) if (known < 0) { gutters.push(new SingleGutterView(this.view, conf)) } else { this.gutters[known].update(update) gutters.push(this.gutters[known]) } } for (let g of this.gutters) { g.dom.remove() if (gutters.indexOf(g) < 0) g.destroy() } for (let g of gutters) this.dom.appendChild(g.dom) this.gutters = gutters } return change } destroy() { for (let view of this.gutters) view.destroy() this.dom.remove() } }, { provide: plugin => EditorView.scrollMargins.of(view => { let value = view.plugin(plugin) if (!value || value.gutters.length == 0 || !value.fixed) return null return view.textDirection == Direction.LTR ? {left: value.dom.offsetWidth * view.scaleX} : {right: value.dom.offsetWidth * view.scaleX} }) }) function asArray(val: T | readonly T[]) { return (Array.isArray(val) ? val : [val]) as readonly T[] } function advanceCursor(cursor: RangeCursor, collect: GutterMarker[], pos: number) { while (cursor.value && cursor.from <= pos) { if (cursor.from == pos) collect.push(cursor.value) cursor.next() } } class UpdateContext { cursor: RangeCursor i = 0 constructor(readonly gutter: SingleGutterView, viewport: {from: number, to: number}, public height: number) { this.cursor = RangeSet.iter(gutter.markers, viewport.from) } addElement(view: EditorView, block: BlockInfo, markers: readonly GutterMarker[]) { let {gutter} = this, above = (block.top - this.height) / view.scaleY, height = block.height / view.scaleY if (this.i == gutter.elements.length) { let newElt = new GutterElement(view, height, above, markers) gutter.elements.push(newElt) gutter.dom.appendChild(newElt.dom) } else { gutter.elements[this.i].update(view, height, above, markers) } this.height = block.bottom this.i++ } line(view: EditorView, line: BlockInfo, extraMarkers: readonly GutterMarker[]) { let localMarkers: GutterMarker[] = [] advanceCursor(this.cursor, localMarkers, line.from) if (extraMarkers.length) localMarkers = localMarkers.concat(extraMarkers) let forLine = this.gutter.config.lineMarker(view, line, localMarkers) if (forLine) localMarkers.unshift(forLine) let gutter = this.gutter if (localMarkers.length == 0 && !gutter.config.renderEmptyElements) return this.addElement(view, line, localMarkers) } widget(view: EditorView, block: BlockInfo) { let marker = this.gutter.config.widgetMarker(view, block.widget!, block) if (marker) this.addElement(view, block, [marker]) } finish() { let gutter = this.gutter while (gutter.elements.length > this.i) { let last = gutter.elements.pop()! gutter.dom.removeChild(last.dom) last.destroy() } } } class SingleGutterView { dom: HTMLElement elements: GutterElement[] = [] markers: readonly RangeSet[] spacer: GutterElement | null = null constructor(public view: EditorView, public config: Required) { this.dom = document.createElement("div") this.dom.className = "cm-gutter" + (this.config.class ? " " + this.config.class : "") for (let prop in config.domEventHandlers) { this.dom.addEventListener(prop, (event: Event) => { let target = event.target as HTMLElement, y if (target != this.dom && this.dom.contains(target)) { while (target.parentNode != this.dom) target = target.parentNode as HTMLElement let rect = target.getBoundingClientRect() y = (rect.top + rect.bottom) / 2 } else { y = (event as MouseEvent).clientY } let line = view.lineBlockAtHeight(y - view.documentTop) if (config.domEventHandlers[prop](view, line, event)) event.preventDefault() }) } this.markers = asArray(config.markers(view)) if (config.initialSpacer) { this.spacer = new GutterElement(view, 0, 0, [config.initialSpacer(view)]) this.dom.appendChild(this.spacer.dom) this.spacer.dom.style.cssText += "visibility: hidden; pointer-events: none" } } update(update: ViewUpdate) { let prevMarkers = this.markers this.markers = asArray(this.config.markers(update.view)) if (this.spacer && this.config.updateSpacer) { let updated = this.config.updateSpacer(this.spacer.markers[0], update) if (updated != this.spacer.markers[0]) this.spacer.update(update.view, 0, 0, [updated]) } let vp = update.view.viewport return !RangeSet.eq(this.markers, prevMarkers, vp.from, vp.to) || (this.config.lineMarkerChange ? this.config.lineMarkerChange(update) : false) } destroy() { for (let elt of this.elements) elt.destroy() } } class GutterElement { dom: HTMLElement height: number = -1 above: number = 0 markers: readonly GutterMarker[] = [] constructor(view: EditorView, height: number, above: number, markers: readonly GutterMarker[]) { this.dom = document.createElement("div") this.dom.className = "cm-gutterElement" this.update(view, height, above, markers) } update(view: EditorView, height: number, above: number, markers: readonly GutterMarker[]) { if (this.height != height) { this.height = height this.dom.style.height = height + "px" } if (this.above != above) this.dom.style.marginTop = (this.above = above) ? above + "px" : "" if (!sameMarkers(this.markers, markers)) this.setMarkers(view, markers) } setMarkers(view: EditorView, markers: readonly GutterMarker[]) { let cls = "cm-gutterElement", domPos = this.dom.firstChild for (let iNew = 0, iOld = 0;;) { let skipTo = iOld, marker = iNew < markers.length ? markers[iNew++] : null, matched = false if (marker) { let c = marker.elementClass if (c) cls += " " + c for (let i = iOld; i < this.markers.length; i++) if (this.markers[i].compare(marker)) { skipTo = i; matched = true; break } } else { skipTo = this.markers.length } while (iOld < skipTo) { let next = this.markers[iOld++] if (next.toDOM) { next.destroy(domPos!) let after = domPos!.nextSibling domPos!.remove() domPos = after } } if (!marker) break if (marker.toDOM) { if (matched) domPos = domPos!.nextSibling else this.dom.insertBefore(marker.toDOM(view), domPos) } if (matched) iOld++ } this.dom.className = cls this.markers = markers } destroy() { this.setMarkers(null as any, []) // First argument not used unless creating markers } } function sameMarkers(a: readonly GutterMarker[], b: readonly GutterMarker[]): boolean { if (a.length != b.length) return false for (let i = 0; i < a.length; i++) if (!a[i].compare(b[i])) return false return true } interface LineNumberConfig { /// How to display line numbers. Defaults to simply converting them /// to string. formatNumber?: (lineNo: number, state: EditorState) => string /// Supply event handlers for DOM events on this gutter. domEventHandlers?: Handlers } /// Facet used to provide markers to the line number gutter. export const lineNumberMarkers = Facet.define>() const lineNumberConfig = Facet.define>({ combine(values) { return combineConfig>(values, {formatNumber: String, domEventHandlers: {}}, { domEventHandlers(a: Handlers, b: Handlers) { let result: Handlers = Object.assign({}, a) for (let event in b) { let exists = result[event], add = b[event] result[event] = exists ? (view, line, event) => exists(view, line, event) || add(view, line, event) : add } return result } }) } }) class NumberMarker extends GutterMarker { constructor(readonly number: string) { super() } eq(other: NumberMarker) { return this.number == other.number } toDOM() { return document.createTextNode(this.number) } } function formatNumber(view: EditorView, number: number) { return view.state.facet(lineNumberConfig).formatNumber(number, view.state) } const lineNumberGutter = activeGutters.compute([lineNumberConfig], state => ({ class: "cm-lineNumbers", renderEmptyElements: false, markers(view: EditorView) { return view.state.facet(lineNumberMarkers) }, lineMarker(view, line, others) { if (others.some(m => m.toDOM)) return null return new NumberMarker(formatNumber(view, view.state.doc.lineAt(line.from).number)) }, widgetMarker: () => null, lineMarkerChange: update => update.startState.facet(lineNumberConfig) != update.state.facet(lineNumberConfig), initialSpacer(view: EditorView) { return new NumberMarker(formatNumber(view, maxLineNumber(view.state.doc.lines))) }, updateSpacer(spacer: GutterMarker, update: ViewUpdate) { let max = formatNumber(update.view, maxLineNumber(update.view.state.doc.lines)) return max == (spacer as NumberMarker).number ? spacer : new NumberMarker(max) }, domEventHandlers: state.facet(lineNumberConfig).domEventHandlers })) /// Create a line number gutter extension. export function lineNumbers(config: LineNumberConfig = {}): Extension { return [ lineNumberConfig.of(config), gutters(), lineNumberGutter ] } function maxLineNumber(lines: number) { let last = 9 while (last < lines) last = last * 10 + 9 return last } const activeLineGutterMarker = new class extends GutterMarker { elementClass = "cm-activeLineGutter" } const activeLineGutterHighlighter = gutterLineClass.compute(["selection"], state => { let marks = [], last = -1 for (let range of state.selection.ranges) { let linePos = state.doc.lineAt(range.head).from if (linePos > last) { last = linePos marks.push(activeLineGutterMarker.range(linePos)) } } return RangeSet.of(marks) }) /// Returns an extension that adds a `cm-activeLineGutter` class to /// all gutter elements on the [active /// line](#view.highlightActiveLine). export function highlightActiveLineGutter() { return activeLineGutterHighlighter } view-6.26.3/src/heightmap.ts000066400000000000000000000663021460616253600157070ustar00rootroot00000000000000import {Text, ChangeSet, RangeSet, SpanIterator} from "@codemirror/state" import {DecorationSet, PointDecoration, Decoration, BlockType, addRange, WidgetType} from "./decoration" import {ChangedRange} from "./extension" const wrappingWhiteSpace = ["pre-wrap", "normal", "pre-line", "break-spaces"] export class HeightOracle { doc: Text = Text.empty heightSamples: {[key: number]: boolean} = {} lineHeight: number = 14 // The height of an entire line (line-height) charWidth: number = 7 textHeight: number = 14 // The height of the actual font (font-size) lineLength: number = 30 // Used to track, during updateHeight, if any actual heights changed heightChanged: boolean = false constructor(public lineWrapping: boolean) {} heightForGap(from: number, to: number): number { let lines = this.doc.lineAt(to).number - this.doc.lineAt(from).number + 1 if (this.lineWrapping) lines += Math.max(0, Math.ceil(((to - from) - (lines * this.lineLength * 0.5)) / this.lineLength)) return this.lineHeight * lines } heightForLine(length: number): number { if (!this.lineWrapping) return this.lineHeight let lines = 1 + Math.max(0, Math.ceil((length - this.lineLength) / (this.lineLength - 5))) return lines * this.lineHeight } setDoc(doc: Text): this { this.doc = doc; return this } mustRefreshForWrapping(whiteSpace: string): boolean { return (wrappingWhiteSpace.indexOf(whiteSpace) > -1) != this.lineWrapping } mustRefreshForHeights(lineHeights: number[]): boolean { let newHeight = false for (let i = 0; i < lineHeights.length; i++) { let h = lineHeights[i] if (h < 0) { i++ } else if (!this.heightSamples[Math.floor(h * 10)]) { // Round to .1 pixels newHeight = true this.heightSamples[Math.floor(h * 10)] = true } } return newHeight } refresh(whiteSpace: string, lineHeight: number, charWidth: number, textHeight: number, lineLength: number, knownHeights: number[]): boolean { let lineWrapping = wrappingWhiteSpace.indexOf(whiteSpace) > -1 let changed = Math.round(lineHeight) != Math.round(this.lineHeight) || this.lineWrapping != lineWrapping this.lineWrapping = lineWrapping this.lineHeight = lineHeight this.charWidth = charWidth this.textHeight = textHeight this.lineLength = lineLength if (changed) { this.heightSamples = {} for (let i = 0; i < knownHeights.length; i++) { let h = knownHeights[i] if (h < 0) i++ else this.heightSamples[Math.floor(h * 10)] = true } } return changed } } // This object is used by `updateHeight` to make DOM measurements // arrive at the right nides. The `heights` array is a sequence of // block heights, starting from position `from`. export class MeasuredHeights { public index = 0 constructor(readonly from: number, readonly heights: number[]) {} get more() { return this.index < this.heights.length } } /// Record used to represent information about a block-level element /// in the editor view. export class BlockInfo { /// @internal constructor( /// The start of the element in the document. readonly from: number, /// The length of the element. readonly length: number, /// The top position of the element (relative to the top of the /// document). readonly top: number, /// Its height. readonly height: number, /// @internal Weird packed field that holds an array of children /// for composite blocks, a decoration for block widgets, and a /// number indicating the amount of widget-create line breaks for /// text blocks. readonly _content: readonly BlockInfo[] | PointDecoration | number ) {} /// The type of element this is. When querying lines, this may be /// an array of all the blocks that make up the line. get type(): BlockType | readonly BlockInfo[] { return typeof this._content == "number" ? BlockType.Text : Array.isArray(this._content) ? this._content : (this._content as PointDecoration).type } /// The end of the element as a document position. get to() { return this.from + this.length } /// The bottom position of the element. get bottom() { return this.top + this.height } /// If this is a widget block, this will return the widget /// associated with it. get widget(): WidgetType | null { return this._content instanceof PointDecoration ? this._content.widget : null } /// If this is a textblock, this holds the number of line breaks /// that appear in widgets inside the block. get widgetLineBreaks(): number { return typeof this._content == "number" ? this._content : 0 } /// @internal join(other: BlockInfo) { let content = (Array.isArray(this._content) ? this._content : [this]) .concat(Array.isArray(other._content) ? other._content : [other]) return new BlockInfo(this.from, this.length + other.length, this.top, this.height + other.height, content) } } export enum QueryType { ByPos, ByHeight, ByPosNoHeight } const enum Flag { Break = 1, Outdated = 2, SingleLine = 4 } const Epsilon = 1e-3 export abstract class HeightMap { constructor( public length: number, // The number of characters covered public height: number, // Height of this part of the document public flags: number = Flag.Outdated ) {} size!: number get outdated() { return (this.flags & Flag.Outdated) > 0 } set outdated(value) { this.flags = (value ? Flag.Outdated : 0) | (this.flags & ~Flag.Outdated) } abstract blockAt(height: number, oracle: HeightOracle, top: number, offset: number): BlockInfo abstract lineAt(value: number, type: QueryType, oracle: HeightOracle, top: number, offset: number): BlockInfo abstract forEachLine(from: number, to: number, oracle: HeightOracle, top: number, offset: number, f: (line: BlockInfo) => void): void abstract updateHeight(oracle: HeightOracle, offset?: number, force?: boolean, measured?: MeasuredHeights): HeightMap abstract toString(): void setHeight(oracle: HeightOracle, height: number) { if (this.height != height) { if (Math.abs(this.height - height) > Epsilon) oracle.heightChanged = true this.height = height } } // Base case is to replace a leaf node, which simply builds a tree // from the new nodes and returns that (HeightMapBranch and // HeightMapGap override this to actually use from/to) replace(_from: number, _to: number, nodes: (HeightMap | null)[]): HeightMap { return HeightMap.of(nodes) } // Again, these are base cases, and are overridden for branch and gap nodes. decomposeLeft(_to: number, result: (HeightMap | null)[]) { result.push(this) } decomposeRight(_from: number, result: (HeightMap | null)[]) { result.push(this) } applyChanges(decorations: readonly DecorationSet[], oldDoc: Text, oracle: HeightOracle, changes: readonly ChangedRange[]): HeightMap { let me: HeightMap = this, doc = oracle.doc for (let i = changes.length - 1; i >= 0; i--) { let {fromA, toA, fromB, toB} = changes[i] let start = me.lineAt(fromA, QueryType.ByPosNoHeight, oracle.setDoc(oldDoc), 0, 0) let end = start.to >= toA ? start : me.lineAt(toA, QueryType.ByPosNoHeight, oracle, 0, 0) toB += end.to - toA; toA = end.to while (i > 0 && start.from <= changes[i - 1].toA) { fromA = changes[i - 1].fromA fromB = changes[i - 1].fromB i-- if (fromA < start.from) start = me.lineAt(fromA, QueryType.ByPosNoHeight, oracle, 0, 0) } fromB += start.from - fromA; fromA = start.from let nodes = NodeBuilder.build(oracle.setDoc(doc), decorations, fromB, toB) me = me.replace(fromA, toA, nodes) } return me.updateHeight(oracle, 0) } static empty(): HeightMap { return new HeightMapText(0, 0) } // nodes uses null values to indicate the position of line breaks. // There are never line breaks at the start or end of the array, or // two line breaks next to each other, and the array isn't allowed // to be empty (same restrictions as return value from the builder). static of(nodes: (HeightMap | null)[]): HeightMap { if (nodes.length == 1) return nodes[0] as HeightMap let i = 0, j = nodes.length, before = 0, after = 0 for (;;) { if (i == j) { if (before > after * 2) { let split = nodes[i - 1] as HeightMapBranch if (split.break) nodes.splice(--i, 1, split.left, null, split.right) else nodes.splice(--i, 1, split.left, split.right) j += 1 + split.break before -= split.size } else if (after > before * 2) { let split = nodes[j] as HeightMapBranch if (split.break) nodes.splice(j, 1, split.left, null, split.right) else nodes.splice(j, 1, split.left, split.right) j += 2 + split.break after -= split.size } else { break } } else if (before < after) { let next = nodes[i++] if (next) before += next.size } else { let next = nodes[--j] if (next) after += next.size } } let brk = 0 if (nodes[i - 1] == null) { brk = 1; i-- } else if (nodes[i] == null) { brk = 1; j++ } return new HeightMapBranch(HeightMap.of(nodes.slice(0, i)), brk, HeightMap.of(nodes.slice(j))) } } HeightMap.prototype.size = 1 class HeightMapBlock extends HeightMap { constructor(length: number, height: number, readonly deco: PointDecoration | null) { super(length, height) } blockAt(_height: number, _oracle: HeightOracle, top: number, offset: number) { return new BlockInfo(offset, this.length, top, this.height, this.deco || 0) } lineAt(_value: number, _type: QueryType, oracle: HeightOracle, top: number, offset: number) { return this.blockAt(0, oracle, top, offset) } forEachLine(from: number, to: number, oracle: HeightOracle, top: number, offset: number, f: (line: BlockInfo) => void) { if (from <= offset + this.length && to >= offset) f(this.blockAt(0, oracle, top, offset)) } updateHeight(oracle: HeightOracle, offset: number = 0, _force: boolean = false, measured?: MeasuredHeights) { if (measured && measured.from <= offset && measured.more) this.setHeight(oracle, measured.heights[measured.index++]) this.outdated = false return this } toString() { return `block(${this.length})` } } class HeightMapText extends HeightMapBlock { public collapsed = 0 // Amount of collapsed content in the line public widgetHeight = 0 // Maximum inline widget height public breaks = 0 // Number of widget-introduced line breaks on the line constructor(length: number, height: number) { super(length, height, null) } blockAt(_height: number, _oracle: HeightOracle, top: number, offset: number) { return new BlockInfo(offset, this.length, top, this.height, this.breaks) } replace(_from: number, _to: number, nodes: (HeightMap | null)[]): HeightMap { let node = nodes[0] if (nodes.length == 1 && (node instanceof HeightMapText || node instanceof HeightMapGap && (node.flags & Flag.SingleLine)) && Math.abs(this.length - node.length) < 10) { if (node instanceof HeightMapGap) node = new HeightMapText(node.length, this.height) else node.height = this.height if (!this.outdated) node.outdated = false return node } else { return HeightMap.of(nodes) } } updateHeight(oracle: HeightOracle, offset: number = 0, force: boolean = false, measured?: MeasuredHeights) { if (measured && measured.from <= offset && measured.more) this.setHeight(oracle, measured.heights[measured.index++]) else if (force || this.outdated) this.setHeight(oracle, Math.max(this.widgetHeight, oracle.heightForLine(this.length - this.collapsed)) + this.breaks * oracle.lineHeight) this.outdated = false return this } toString() { return `line(${this.length}${this.collapsed ? -this.collapsed : ""}${this.widgetHeight ? ":" + this.widgetHeight : ""})` } } class HeightMapGap extends HeightMap { constructor(length: number) { super(length, 0) } private heightMetrics(oracle: HeightOracle, offset: number): { firstLine: number, lastLine: number, perLine: number, perChar: number } { let firstLine = oracle.doc.lineAt(offset).number, lastLine = oracle.doc.lineAt(offset + this.length).number let lines = lastLine - firstLine + 1 let perLine, perChar = 0 if (oracle.lineWrapping) { let totalPerLine = Math.min(this.height, oracle.lineHeight * lines) perLine = totalPerLine / lines if (this.length > lines + 1) perChar = (this.height - totalPerLine) / (this.length - lines - 1) } else { perLine = this.height / lines } return {firstLine, lastLine, perLine, perChar} } blockAt(height: number, oracle: HeightOracle, top: number, offset: number) { let {firstLine, lastLine, perLine, perChar} = this.heightMetrics(oracle, offset) if (oracle.lineWrapping) { let guess = offset + (height < oracle.lineHeight ? 0 : Math.round(Math.max(0, Math.min(1, (height - top) / this.height)) * this.length)) let line = oracle.doc.lineAt(guess), lineHeight = perLine + line.length * perChar let lineTop = Math.max(top, height - lineHeight / 2) return new BlockInfo(line.from, line.length, lineTop, lineHeight, 0) } else { let line = Math.max(0, Math.min(lastLine - firstLine, Math.floor((height - top) / perLine))) let {from, length} = oracle.doc.line(firstLine + line) return new BlockInfo(from, length, top + perLine * line, perLine, 0) } } lineAt(value: number, type: QueryType, oracle: HeightOracle, top: number, offset: number) { if (type == QueryType.ByHeight) return this.blockAt(value, oracle, top, offset) if (type == QueryType.ByPosNoHeight) { let {from, to} = oracle.doc.lineAt(value) return new BlockInfo(from, to - from, 0, 0, 0) } let {firstLine, perLine, perChar} = this.heightMetrics(oracle, offset) let line = oracle.doc.lineAt(value), lineHeight = perLine + line.length * perChar let linesAbove = line.number - firstLine let lineTop = top + perLine * linesAbove + perChar * (line.from - offset - linesAbove) return new BlockInfo(line.from, line.length, Math.max(top, Math.min(lineTop, top + this.height - lineHeight)), lineHeight, 0) } forEachLine(from: number, to: number, oracle: HeightOracle, top: number, offset: number, f: (line: BlockInfo) => void) { from = Math.max(from, offset); to = Math.min(to, offset + this.length) let {firstLine, perLine, perChar} = this.heightMetrics(oracle, offset) for (let pos = from, lineTop = top; pos <= to;) { let line = oracle.doc.lineAt(pos) if (pos == from) { let linesAbove = line.number - firstLine lineTop += perLine * linesAbove + perChar * (from - offset - linesAbove) } let lineHeight = perLine + perChar * line.length f(new BlockInfo(line.from, line.length, lineTop, lineHeight, 0)) lineTop += lineHeight pos = line.to + 1 } } replace(from: number, to: number, nodes: (HeightMap | null)[]): HeightMap { let after = this.length - to if (after > 0) { let last = nodes[nodes.length - 1] if (last instanceof HeightMapGap) nodes[nodes.length - 1] = new HeightMapGap(last.length + after) else nodes.push(null, new HeightMapGap(after - 1)) } if (from > 0) { let first = nodes[0] if (first instanceof HeightMapGap) nodes[0] = new HeightMapGap(from + first.length) else nodes.unshift(new HeightMapGap(from - 1), null) } return HeightMap.of(nodes) } decomposeLeft(to: number, result: (HeightMap | null)[]) { result.push(new HeightMapGap(to - 1), null) } decomposeRight(from: number, result: (HeightMap | null)[]) { result.push(null, new HeightMapGap(this.length - from - 1)) } updateHeight(oracle: HeightOracle, offset: number = 0, force: boolean = false, measured?: MeasuredHeights): HeightMap { let end = offset + this.length if (measured && measured.from <= offset + this.length && measured.more) { // Fill in part of this gap with measured lines. We know there // can't be widgets or collapsed ranges in those lines, because // they would already have been added to the heightmap (gaps // only contain plain text). let nodes = [], pos = Math.max(offset, measured.from), singleHeight = -1 if (measured.from > offset) nodes.push(new HeightMapGap(measured.from - offset - 1).updateHeight(oracle, offset)) while (pos <= end && measured.more) { let len = oracle.doc.lineAt(pos).length if (nodes.length) nodes.push(null) let height = measured.heights[measured.index++] if (singleHeight == -1) singleHeight = height else if (Math.abs(height - singleHeight) >= Epsilon) singleHeight = -2 let line = new HeightMapText(len, height) line.outdated = false nodes.push(line) pos += len + 1 } if (pos <= end) nodes.push(null, new HeightMapGap(end - pos).updateHeight(oracle, pos)) let result = HeightMap.of(nodes) if (singleHeight < 0 || Math.abs(result.height - this.height) >= Epsilon || Math.abs(singleHeight - this.heightMetrics(oracle, offset).perLine) >= Epsilon) oracle.heightChanged = true return result } else if (force || this.outdated) { this.setHeight(oracle, oracle.heightForGap(offset, offset + this.length)) this.outdated = false } return this } toString() { return `gap(${this.length})` } } class HeightMapBranch extends HeightMap { size: number constructor(public left: HeightMap, brk: number, public right: HeightMap) { super(left.length + brk + right.length, left.height + right.height, brk | (left.outdated || right.outdated ? Flag.Outdated : 0)) this.size = left.size + right.size } get break() { return this.flags & Flag.Break } blockAt(height: number, oracle: HeightOracle, top: number, offset: number) { let mid = top + this.left.height return height < mid ? this.left.blockAt(height, oracle, top, offset) : this.right.blockAt(height, oracle, mid, offset + this.left.length + this.break) } lineAt(value: number, type: QueryType, oracle: HeightOracle, top: number, offset: number) { let rightTop = top + this.left.height, rightOffset = offset + this.left.length + this.break let left = type == QueryType.ByHeight ? value < rightTop : value < rightOffset let base = left ? this.left.lineAt(value, type, oracle, top, offset) : this.right.lineAt(value, type, oracle, rightTop, rightOffset) if (this.break || (left ? base.to < rightOffset : base.from > rightOffset)) return base let subQuery = type == QueryType.ByPosNoHeight ? QueryType.ByPosNoHeight : QueryType.ByPos if (left) return base.join(this.right.lineAt(rightOffset, subQuery, oracle, rightTop, rightOffset)) else return this.left.lineAt(rightOffset, subQuery, oracle, top, offset).join(base) } forEachLine(from: number, to: number, oracle: HeightOracle, top: number, offset: number, f: (line: BlockInfo) => void) { let rightTop = top + this.left.height, rightOffset = offset + this.left.length + this.break if (this.break) { if (from < rightOffset) this.left.forEachLine(from, to, oracle, top, offset, f) if (to >= rightOffset) this.right.forEachLine(from, to, oracle, rightTop, rightOffset, f) } else { let mid = this.lineAt(rightOffset, QueryType.ByPos, oracle, top, offset) if (from < mid.from) this.left.forEachLine(from, mid.from - 1, oracle, top, offset, f) if (mid.to >= from && mid.from <= to) f(mid) if (to > mid.to) this.right.forEachLine(mid.to + 1, to, oracle, rightTop, rightOffset, f) } } replace(from: number, to: number, nodes: (HeightMap | null)[]): HeightMap { let rightStart = this.left.length + this.break if (to < rightStart) return this.balanced(this.left.replace(from, to, nodes), this.right) if (from > this.left.length) return this.balanced(this.left, this.right.replace(from - rightStart, to - rightStart, nodes)) let result: (HeightMap | null)[] = [] if (from > 0) this.decomposeLeft(from, result) let left = result.length for (let node of nodes) result.push(node) if (from > 0) mergeGaps(result, left - 1) if (to < this.length) { let right = result.length this.decomposeRight(to, result) mergeGaps(result, right) } return HeightMap.of(result) } decomposeLeft(to: number, result: (HeightMap | null)[]) { let left = this.left.length if (to <= left) return this.left.decomposeLeft(to, result) result.push(this.left) if (this.break) { left++ if (to >= left) result.push(null) } if (to > left) this.right.decomposeLeft(to - left, result) } decomposeRight(from: number, result: (HeightMap | null)[]) { let left = this.left.length, right = left + this.break if (from >= right) return this.right.decomposeRight(from - right, result) if (from < left) this.left.decomposeRight(from, result) if (this.break && from < right) result.push(null) result.push(this.right) } balanced(left: HeightMap, right: HeightMap): HeightMap { if (left.size > 2 * right.size || right.size > 2 * left.size) return HeightMap.of(this.break ? [left, null, right] : [left, right]) this.left = left; this.right = right this.height = left.height + right.height this.outdated = left.outdated || right.outdated this.size = left.size + right.size this.length = left.length + this.break + right.length return this } updateHeight(oracle: HeightOracle, offset: number = 0, force: boolean = false, measured?: MeasuredHeights): HeightMap { let {left, right} = this, rightStart = offset + left.length + this.break, rebalance: any = null if (measured && measured.from <= offset + left.length && measured.more) rebalance = left = left.updateHeight(oracle, offset, force, measured) else left.updateHeight(oracle, offset, force) if (measured && measured.from <= rightStart + right.length && measured.more) rebalance = right = right.updateHeight(oracle, rightStart, force, measured) else right.updateHeight(oracle, rightStart, force) if (rebalance) return this.balanced(left, right) this.height = this.left.height + this.right.height this.outdated = false return this } toString() { return this.left + (this.break ? " " : "-") + this.right } } function mergeGaps(nodes: (HeightMap | null)[], around: number) { let before, after if (nodes[around] == null && (before = nodes[around - 1]) instanceof HeightMapGap && (after = nodes[around + 1]) instanceof HeightMapGap) nodes.splice(around - 1, 3, new HeightMapGap(before.length + 1 + after.length)) } const relevantWidgetHeight = 5 class NodeBuilder implements SpanIterator { nodes: (HeightMap | null)[] = [] writtenTo: number lineStart = -1 lineEnd = -1 covering: HeightMapBlock | null = null constructor(public pos: number, public oracle: HeightOracle) { this.writtenTo = pos } get isCovered() { return this.covering && this.nodes[this.nodes.length - 1] == this.covering } span(_from: number, to: number) { if (this.lineStart > -1) { let end = Math.min(to, this.lineEnd), last = this.nodes[this.nodes.length - 1] if (last instanceof HeightMapText) last.length += end - this.pos else if (end > this.pos || !this.isCovered) this.nodes.push(new HeightMapText(end - this.pos, -1)) this.writtenTo = end if (to > end) { this.nodes.push(null) this.writtenTo++ this.lineStart = -1 } } this.pos = to } point(from: number, to: number, deco: PointDecoration) { if (from < to || deco.heightRelevant) { let height = deco.widget ? deco.widget.estimatedHeight : 0 let breaks = deco.widget ? deco.widget.lineBreaks : 0 if (height < 0) height = this.oracle.lineHeight let len = to - from if (deco.block) { this.addBlock(new HeightMapBlock(len, height, deco)) } else if (len || breaks || height >= relevantWidgetHeight) { this.addLineDeco(height, breaks, len) } } else if (to > from) { this.span(from, to) } if (this.lineEnd > -1 && this.lineEnd < this.pos) this.lineEnd = this.oracle.doc.lineAt(this.pos).to } enterLine() { if (this.lineStart > -1) return let {from, to} = this.oracle.doc.lineAt(this.pos) this.lineStart = from; this.lineEnd = to if (this.writtenTo < from) { if (this.writtenTo < from - 1 || this.nodes[this.nodes.length - 1] == null) this.nodes.push(this.blankContent(this.writtenTo, from - 1)) this.nodes.push(null) } if (this.pos > from) this.nodes.push(new HeightMapText(this.pos - from, -1)) this.writtenTo = this.pos } blankContent(from: number, to: number) { let gap = new HeightMapGap(to - from) if (this.oracle.doc.lineAt(from).to == to) gap.flags |= Flag.SingleLine return gap } ensureLine() { this.enterLine() let last = this.nodes.length ? this.nodes[this.nodes.length - 1] : null if (last instanceof HeightMapText) return last let line = new HeightMapText(0, -1) this.nodes.push(line) return line } addBlock(block: HeightMapBlock) { this.enterLine() let deco = block.deco if (deco && deco.startSide > 0 && !this.isCovered) this.ensureLine() this.nodes.push(block) this.writtenTo = this.pos = this.pos + block.length if (deco && deco.endSide > 0) this.covering = block } addLineDeco(height: number, breaks: number, length: number) { let line = this.ensureLine() line.length += length line.collapsed += length line.widgetHeight = Math.max(line.widgetHeight, height) line.breaks += breaks this.writtenTo = this.pos = this.pos + length } finish(from: number) { let last = this.nodes.length == 0 ? null : this.nodes[this.nodes.length - 1] if (this.lineStart > -1 && !(last instanceof HeightMapText) && !this.isCovered) this.nodes.push(new HeightMapText(0, -1)) else if (this.writtenTo < this.pos || last == null) this.nodes.push(this.blankContent(this.writtenTo, this.pos)) let pos = from for (let node of this.nodes) { if (node instanceof HeightMapText) node.updateHeight(this.oracle, pos) pos += node ? node.length : 1 } return this.nodes } // Always called with a region that on both sides either stretches // to a line break or the end of the document. // The returned array uses null to indicate line breaks, but never // starts or ends in a line break, or has multiple line breaks next // to each other. static build(oracle: HeightOracle, decorations: readonly DecorationSet[], from: number, to: number): (HeightMap | null)[] { let builder = new NodeBuilder(from, oracle) RangeSet.spans(decorations, from, to, builder, 0) return builder.finish(from) } } export function heightRelevantDecoChanges(a: readonly DecorationSet[], b: readonly DecorationSet[], diff: ChangeSet) { let comp = new DecorationComparator RangeSet.compare(a, b, diff, comp, 0) return comp.changes } class DecorationComparator { changes: number[] = [] compareRange() {} comparePoint(from: number, to: number, a: Decoration | null, b: Decoration | null) { if (from < to || a && a.heightRelevant || b && b.heightRelevant) addRange(from, to, this.changes, 5) } } view-6.26.3/src/highlight-space.ts000066400000000000000000000032171460616253600167750ustar00rootroot00000000000000import {Extension} from "@codemirror/state" import {ViewPlugin} from "./extension" import {MatchDecorator} from "./matchdecorator" import {Decoration} from "./decoration" const WhitespaceDeco = new Map() function getWhitespaceDeco(space: string): Decoration { let deco = WhitespaceDeco.get(space) if (!deco) WhitespaceDeco.set(space, deco = Decoration.mark({ attributes: space === "\t" ? { class: "cm-highlightTab", } : { class: "cm-highlightSpace", "data-display": space.replace(/ /g, "·") } })) return deco } function matcher(decorator: MatchDecorator): Extension { return ViewPlugin.define(view => ({ decorations: decorator.createDeco(view), update(u): void { this.decorations = decorator.updateDeco(u, this.decorations) }, }), { decorations: v => v.decorations }) } const whitespaceHighlighter = matcher(new MatchDecorator({ regexp: /\t| +/g, decoration: match => getWhitespaceDeco(match[0]), boundary: /\S/, })) /// Returns an extension that highlights whitespace, adding a /// `cm-highlightSpace` class to stretches of spaces, and a /// `cm-highlightTab` class to individual tab characters. By default, /// the former are shown as faint dots, and the latter as arrows. export function highlightWhitespace() { return whitespaceHighlighter } const trailingHighlighter = matcher(new MatchDecorator({ regexp: /\s+$/g, decoration: Decoration.mark({class: "cm-trailingSpace"}), boundary: /\S/, })) /// Returns an extension that adds a `cm-trailingSpace` class to all /// trailing whitespace. export function highlightTrailingWhitespace() { return trailingHighlighter } view-6.26.3/src/index.ts000066400000000000000000000033061460616253600150430ustar00rootroot00000000000000export {EditorView, EditorViewConfig, DOMEventMap, DOMEventHandlers} from "./editorview" export {Command, ViewPlugin, PluginValue, PluginSpec, ViewUpdate, logException} from "./extension" export {Decoration, DecorationSet, WidgetType, BlockType} from "./decoration" export {BlockInfo} from "./heightmap" export {MouseSelectionStyle} from "./input" export {BidiSpan, Direction} from "./bidi" export {KeyBinding, keymap, runScopeHandlers} from "./keymap" export {drawSelection, getDrawSelectionConfig} from "./draw-selection" export {dropCursor} from "./dropcursor" export {highlightSpecialChars} from "./special-chars" export {scrollPastEnd} from "./scrollpastend" export {highlightActiveLine} from "./active-line" export {placeholder} from "./placeholder" export {Rect} from "./dom" export {layer, LayerMarker, RectangleMarker} from "./layer" export {MatchDecorator} from "./matchdecorator" export {rectangularSelection, crosshairCursor} from "./rectangular-selection" export {showTooltip, Tooltip, TooltipView, tooltips, getTooltip, hoverTooltip, hasHoverTooltips, closeHoverTooltips, repositionTooltips} from "./tooltip" export {showPanel, PanelConstructor, Panel, getPanel, panels} from "./panel" export {lineNumbers, highlightActiveLineGutter, gutter, gutters, GutterMarker, gutterLineClass, lineNumberMarkers} from "./gutter" export {highlightWhitespace, highlightTrailingWhitespace} from "./highlight-space" import {HeightMap, HeightOracle, MeasuredHeights, QueryType} from "./heightmap" import {ChangedRange} from "./extension" import {computeOrder, moveVisually} from "./bidi" /// @internal export const __test = {HeightMap, HeightOracle, MeasuredHeights, QueryType, ChangedRange, computeOrder, moveVisually} view-6.26.3/src/inlineview.ts000066400000000000000000000300051460616253600161010ustar00rootroot00000000000000import {Text as DocText} from "@codemirror/state" import {ContentView, DOMPos, ViewFlag, mergeChildrenInto, noChildren} from "./contentview" import {WidgetType, MarkDecoration} from "./decoration" import {Rect, flattenRect, textRange, clientRectsFor, clearAttributes} from "./dom" import {DocView} from "./docview" import browser from "./browser" import {EditorView} from "./editorview" const MaxJoinLen = 256 export class TextView extends ContentView { children!: ContentView[] dom!: Text | null constructor(public text: string) { super() } get length() { return this.text.length } createDOM(textDOM?: Node) { this.setDOM(textDOM || document.createTextNode(this.text)) } sync(view: EditorView, track?: {node: Node, written: boolean}) { if (!this.dom) this.createDOM() if (this.dom!.nodeValue != this.text) { if (track && track.node == this.dom) track.written = true this.dom!.nodeValue = this.text } } reuseDOM(dom: Node) { if (dom.nodeType == 3) this.createDOM(dom) } merge(from: number, to: number, source: ContentView | null): boolean { if ((this.flags & ViewFlag.Composition) || source && (!(source instanceof TextView) || this.length - (to - from) + source.length > MaxJoinLen || (source.flags & ViewFlag.Composition))) return false this.text = this.text.slice(0, from) + (source ? source.text : "") + this.text.slice(to) this.markDirty() return true } split(from: number) { let result = new TextView(this.text.slice(from)) this.text = this.text.slice(0, from) this.markDirty() result.flags |= this.flags & ViewFlag.Composition return result } localPosFromDOM(node: Node, offset: number): number { return node == this.dom ? offset : offset ? this.text.length : 0 } domAtPos(pos: number) { return new DOMPos(this.dom!, pos) } domBoundsAround(_from: number, _to: number, offset: number) { return {from: offset, to: offset + this.length, startDOM: this.dom, endDOM: this.dom!.nextSibling} } coordsAt(pos: number, side: number): Rect | null { return textCoords(this.dom!, pos, side) } } export class MarkView extends ContentView { dom!: HTMLElement | null constructor(readonly mark: MarkDecoration, public children: ContentView[] = [], public length = 0) { super() for (let ch of children) ch.setParent(this) } setAttrs(dom: HTMLElement) { clearAttributes(dom) if (this.mark.class) dom.className = this.mark.class if (this.mark.attrs) for (let name in this.mark.attrs) dom.setAttribute(name, this.mark.attrs[name]) return dom } canReuseDOM(other: ContentView) { return super.canReuseDOM(other) && !((this.flags | other.flags) & ViewFlag.Composition) } reuseDOM(node: Node) { if (node.nodeName == this.mark.tagName.toUpperCase()) { this.setDOM(node) this.flags |= ViewFlag.AttrsDirty | ViewFlag.NodeDirty } } sync(view: EditorView, track?: {node: Node, written: boolean}) { if (!this.dom) this.setDOM(this.setAttrs(document.createElement(this.mark.tagName))) else if (this.flags & ViewFlag.AttrsDirty) this.setAttrs(this.dom) super.sync(view, track) } merge(from: number, to: number, source: ContentView | null, _hasStart: boolean, openStart: number, openEnd: number): boolean { if (source && (!(source instanceof MarkView && source.mark.eq(this.mark)) || (from && openStart <= 0) || (to < this.length && openEnd <= 0))) return false mergeChildrenInto(this, from, to, source ? source.children.slice() : [], openStart - 1, openEnd - 1) this.markDirty() return true } split(from: number) { let result = [], off = 0, detachFrom = -1, i = 0 for (let elt of this.children) { let end = off + elt.length if (end > from) result.push(off < from ? elt.split(from - off) : elt) if (detachFrom < 0 && off >= from) detachFrom = i off = end i++ } let length = this.length - from this.length = from if (detachFrom > -1) { this.children.length = detachFrom this.markDirty() } return new MarkView(this.mark, result, length) } domAtPos(pos: number): DOMPos { return inlineDOMAtPos(this, pos) } coordsAt(pos: number, side: number): Rect | null { return coordsInChildren(this, pos, side) } } function textCoords(text: Text, pos: number, side: number): Rect | null { let length = text.nodeValue!.length if (pos > length) pos = length let from = pos, to = pos, flatten = 0 if (pos == 0 && side < 0 || pos == length && side >= 0) { if (!(browser.chrome || browser.gecko)) { // These browsers reliably return valid rectangles for empty ranges if (pos) { from--; flatten = 1 } // FIXME this is wrong in RTL text else if (to < length) { to++; flatten = -1 } } } else { if (side < 0) from--; else if (to < length) to++ } let rects = textRange(text, from, to).getClientRects() if (!rects.length) return null let rect = rects[(flatten ? flatten < 0 : side >= 0) ? 0 : rects.length - 1] if (browser.safari && !flatten && rect.width == 0) rect = Array.prototype.find.call(rects, r => r.width) || rect return flatten ? flattenRect(rect!, flatten < 0) : rect || null } // Also used for collapsed ranges that don't have a placeholder widget! export class WidgetView extends ContentView { children!: ContentView[] dom!: HTMLElement | null prevWidget: WidgetType | null = null static create(widget: WidgetType, length: number, side: number) { return new WidgetView(widget, length, side) } constructor(public widget: WidgetType, public length: number, readonly side: number) { super() } split(from: number) { let result = WidgetView.create(this.widget, this.length - from, this.side) this.length -= from return result } sync(view: EditorView) { if (!this.dom || !this.widget.updateDOM(this.dom, view)) { if (this.dom && this.prevWidget) this.prevWidget.destroy(this.dom) this.prevWidget = null this.setDOM(this.widget.toDOM(view)) if (!this.widget.editable) this.dom!.contentEditable = "false" } } getSide() { return this.side } merge(from: number, to: number, source: ContentView | null, hasStart: boolean, openStart: number, openEnd: number) { if (source && (!(source instanceof WidgetView) || !this.widget.compare(source.widget) || from > 0 && openStart <= 0 || to < this.length && openEnd <= 0)) return false this.length = from + (source ? source.length : 0) + (this.length - to) return true } become(other: ContentView): boolean { if (other instanceof WidgetView && other.side == this.side && this.widget.constructor == other.widget.constructor) { if (!this.widget.compare(other.widget)) this.markDirty(true) if (this.dom && !this.prevWidget) this.prevWidget = this.widget this.widget = other.widget this.length = other.length return true } return false } ignoreMutation(): boolean { return true } ignoreEvent(event: Event): boolean { return this.widget.ignoreEvent(event) } get overrideDOMText(): DocText | null { if (this.length == 0) return DocText.empty let top: ContentView = this while (top.parent) top = top.parent let {view} = top as DocView, text: DocText | undefined = view && view.state.doc, start = this.posAtStart return text ? text.slice(start, start + this.length) : DocText.empty } domAtPos(pos: number) { return (this.length ? pos == 0 : this.side > 0) ? DOMPos.before(this.dom!) : DOMPos.after(this.dom!, pos == this.length) } domBoundsAround() { return null } coordsAt(pos: number, side: number): Rect | null { let custom = this.widget.coordsAt(this.dom!, pos, side) if (custom) return custom let rects = this.dom!.getClientRects(), rect: Rect | null = null if (!rects.length) return null let fromBack = this.side ? this.side < 0 : pos > 0 for (let i = fromBack ? rects.length - 1 : 0;; i += (fromBack ? -1 : 1)) { rect = rects[i] if (pos > 0 ? i == 0 : i == rects.length - 1 || rect.top < rect.bottom) break } return flattenRect(rect, !fromBack) } get isEditable() { return false } get isWidget() { return true } get isHidden() { return this.widget.isHidden } destroy() { super.destroy() if (this.dom) this.widget.destroy(this.dom) } } // These are drawn around uneditable widgets to avoid a number of // browser bugs that show up when the cursor is directly next to // uneditable inline content. export class WidgetBufferView extends ContentView { children!: ContentView[] dom!: HTMLElement | null constructor(readonly side: number) { super() } get length() { return 0 } merge() { return false } become(other: ContentView): boolean { return other instanceof WidgetBufferView && other.side == this.side } split() { return new WidgetBufferView(this.side) } sync() { if (!this.dom) { let dom = document.createElement("img") dom.className = "cm-widgetBuffer" dom.setAttribute("aria-hidden", "true") this.setDOM(dom) } } getSide() { return this.side } domAtPos(pos: number) { return this.side > 0 ? DOMPos.before(this.dom!) : DOMPos.after(this.dom!) } localPosFromDOM() { return 0 } domBoundsAround() { return null } coordsAt(pos: number): Rect | null { return this.dom!.getBoundingClientRect() } get overrideDOMText() { return DocText.empty } get isHidden() { return true } } TextView.prototype.children = WidgetView.prototype.children = WidgetBufferView.prototype.children = noChildren export function inlineDOMAtPos(parent: ContentView, pos: number) { let dom = parent.dom!, {children} = parent, i = 0 for (let off = 0; i < children.length; i++) { let child = children[i], end = off + child.length if (end == off && child.getSide() <= 0) continue if (pos > off && pos < end && child.dom!.parentNode == dom) return child.domAtPos(pos - off) if (pos <= off) break off = end } for (let j = i; j > 0; j--) { let prev = children[j - 1] if (prev.dom!.parentNode == dom) return prev.domAtPos(prev.length) } for (let j = i; j < children.length; j++) { let next = children[j] if (next.dom!.parentNode == dom) return next.domAtPos(0) } return new DOMPos(dom, 0) } // Assumes `view`, if a mark view, has precisely 1 child. export function joinInlineInto(parent: ContentView, view: ContentView, open: number) { let last, {children} = parent if (open > 0 && view instanceof MarkView && children.length && (last = children[children.length - 1]) instanceof MarkView && last.mark.eq(view.mark)) { joinInlineInto(last, view.children[0], open - 1) } else { children.push(view) view.setParent(parent) } parent.length += view.length } export function coordsInChildren(view: ContentView, pos: number, side: number): Rect | null { let before: ContentView | null = null, beforePos = -1, after: ContentView | null = null, afterPos = -1 function scan(view: ContentView, pos: number) { for (let i = 0, off = 0; i < view.children.length && off <= pos; i++) { let child = view.children[i], end = off + child.length if (end >= pos) { if (child.children.length) { scan(child, pos - off) } else if ((!after || after.isHidden && side > 0) && (end > pos || off == end && child.getSide() > 0)) { after = child afterPos = pos - off } else if (off < pos || (off == end && child.getSide() < 0) && !child.isHidden) { before = child beforePos = pos - off } } off = end } } scan(view, pos) let target = (side < 0 ? before : after) || before || after if (target) return (target as ContentView).coordsAt(Math.max(0, target == before ? beforePos : afterPos), side) return fallbackRect(view) } function fallbackRect(view: ContentView) { let last = view.dom!.lastChild if (!last) return (view.dom as HTMLElement).getBoundingClientRect() let rects = clientRectsFor(last) return rects[rects.length - 1] || null } view-6.26.3/src/input.ts000066400000000000000000001025261460616253600150770ustar00rootroot00000000000000import {EditorSelection, EditorState, SelectionRange, RangeSet, Annotation, Text} from "@codemirror/state" import {EditorView} from "./editorview" import {ContentView} from "./contentview" import {LineView} from "./blockview" import {ViewUpdate, PluginValue, clickAddsSelectionRange, dragMovesSelection as dragBehavior, atomicRanges, logException, mouseSelectionStyle, PluginInstance, focusChangeEffect, getScrollMargins} from "./extension" import browser from "./browser" import {groupAt, skipAtomicRanges} from "./cursor" import {getSelection, focusPreventScroll, Rect, dispatchKey, scrollableParent} from "./dom" // This will also be where dragging info and such goes export class InputState { lastKeyCode: number = 0 lastKeyTime: number = 0 lastTouchTime = 0 lastFocusTime = 0 lastScrollTop = 0 lastScrollLeft = 0 // On iOS, some keys need to have their default behavior happen // (after which we retroactively handle them and reset the DOM) to // avoid messing up the virtual keyboard state. pendingIOSKey: undefined | {key: string, keyCode: number} | KeyboardEvent = undefined lastSelectionOrigin: string | null = null lastSelectionTime: number = 0 lastEscPress: number = 0 lastContextMenu: number = 0 scrollHandlers: ((event: Event) => boolean | void)[] = [] handlers: {[event: string]: { observers: readonly HandlerFunction[], handlers: readonly HandlerFunction[] }} = Object.create(null) // -1 means not in a composition. Otherwise, this counts the number // of changes made during the composition. The count is used to // avoid treating the start state of the composition, before any // changes have been made, as part of the composition. composing = -1 // Tracks whether the next change should be marked as starting the // composition (null means no composition, true means next is the // first, false means first has already been marked for this // composition) compositionFirstChange: boolean | null = null // End time of the previous composition compositionEndedAt = 0 // Used in a kludge to detect when an Enter keypress should be // considered part of the composition on Safari, which fires events // in the wrong order compositionPendingKey = false // Used to categorize changes as part of a composition, even when // the mutation events fire shortly after the compositionend event compositionPendingChange = false mouseSelection: MouseSelection | null = null // When a drag from the editor is active, this points at the range // being dragged. draggedContent: SelectionRange | null = null notifiedFocused: boolean setSelectionOrigin(origin: string) { this.lastSelectionOrigin = origin this.lastSelectionTime = Date.now() } constructor(readonly view: EditorView) { this.handleEvent = this.handleEvent.bind(this) this.notifiedFocused = view.hasFocus // On Safari adding an input event handler somehow prevents an // issue where the composition vanishes when you press enter. if (browser.safari) view.contentDOM.addEventListener("input", () => null) if (browser.gecko) firefoxCopyCutHack(view.contentDOM.ownerDocument) } handleEvent(event: Event) { if (!eventBelongsToEditor(this.view, event) || this.ignoreDuringComposition(event)) return if (event.type == "keydown" && this.keydown(event as KeyboardEvent)) return this.runHandlers(event.type, event) } runHandlers(type: string, event: Event) { let handlers = this.handlers[type] if (handlers) { for (let observer of handlers.observers) observer(this.view, event) for (let handler of handlers.handlers) { if (event.defaultPrevented) break if (handler(this.view, event)) { event.preventDefault(); break } } } } ensureHandlers(plugins: readonly PluginInstance[]) { let handlers = computeHandlers(plugins), prev = this.handlers, dom = this.view.contentDOM for (let type in handlers) if (type != "scroll") { let passive = !handlers[type].handlers.length let exists: (typeof prev)["type"] | null = prev[type] if (exists && passive != !exists.handlers.length) { dom.removeEventListener(type, this.handleEvent) exists = null } if (!exists) dom.addEventListener(type, this.handleEvent, {passive}) } for (let type in prev) if (type != "scroll" && !handlers[type]) dom.removeEventListener(type, this.handleEvent) this.handlers = handlers } keydown(event: KeyboardEvent) { // Must always run, even if a custom handler handled the event this.lastKeyCode = event.keyCode this.lastKeyTime = Date.now() if (event.keyCode == 9 && Date.now() < this.lastEscPress + 2000) return true if (event.keyCode != 27 && modifierCodes.indexOf(event.keyCode) < 0) this.view.inputState.lastEscPress = 0 // Chrome for Android usually doesn't fire proper key events, but // occasionally does, usually surrounded by a bunch of complicated // composition changes. When an enter or backspace key event is // seen, hold off on handling DOM events for a bit, and then // dispatch it. if (browser.android && browser.chrome && !(event as any).synthetic && (event.keyCode == 13 || event.keyCode == 8)) { this.view.observer.delayAndroidKey(event.key, event.keyCode) return true } // Preventing the default behavior of Enter on iOS makes the // virtual keyboard get stuck in the wrong (lowercase) // state. So we let it go through, and then, in // applyDOMChange, notify key handlers of it and reset to // the state they produce. let pending if (browser.ios && !(event as any).synthetic && !event.altKey && !event.metaKey && ((pending = PendingKeys.find(key => key.keyCode == event.keyCode)) && !event.ctrlKey || EmacsyPendingKeys.indexOf(event.key) > -1 && event.ctrlKey && !event.shiftKey)) { this.pendingIOSKey = pending || event setTimeout(() => this.flushIOSKey(), 250) return true } if (event.keyCode != 229) this.view.observer.forceFlush() return false } flushIOSKey(change?: {from: number, to: number, insert: Text}) { let key = this.pendingIOSKey if (!key) return false // This looks like an autocorrection before Enter if (key.key == "Enter" && change && change.from < change.to && /^\S+$/.test(change.insert.toString())) return false this.pendingIOSKey = undefined return dispatchKey(this.view.contentDOM, key.key, key.keyCode, key instanceof KeyboardEvent ? key : undefined) } ignoreDuringComposition(event: Event): boolean { if (!/^key/.test(event.type)) return false if (this.composing > 0) return true // See https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/. // On some input method editors (IMEs), the Enter key is used to // confirm character selection. On Safari, when Enter is pressed, // compositionend and keydown events are sometimes emitted in the // wrong order. The key event should still be ignored, even when // it happens after the compositionend event. if (browser.safari && !browser.ios && this.compositionPendingKey && Date.now() - this.compositionEndedAt < 100) { this.compositionPendingKey = false return true } return false } startMouseSelection(mouseSelection: MouseSelection) { if (this.mouseSelection) this.mouseSelection.destroy() this.mouseSelection = mouseSelection } update(update: ViewUpdate) { if (this.mouseSelection) this.mouseSelection.update(update) if (this.draggedContent && update.docChanged) this.draggedContent = this.draggedContent.map(update.changes) if (update.transactions.length) this.lastKeyCode = this.lastSelectionTime = 0 } destroy() { if (this.mouseSelection) this.mouseSelection.destroy() } } type HandlerFunction = (view: EditorView, event: Event) => boolean | void function bindHandler( plugin: PluginValue, handler: (this: PluginValue, event: Event, view: EditorView) => boolean | void ): HandlerFunction { return (view, event) => { try { return handler.call(plugin, event, view) } catch (e) { logException(view.state, e) } } } function computeHandlers(plugins: readonly PluginInstance[]) { let result: {[event: string]: { observers: HandlerFunction[], handlers: HandlerFunction[] }} = Object.create(null) function record(type: string) { return result[type] || (result[type] = {observers: [], handlers: []}) } for (let plugin of plugins) { let spec = plugin.spec if (spec && spec.domEventHandlers) for (let type in spec.domEventHandlers) { let f = spec.domEventHandlers[type] if (f) record(type).handlers.push(bindHandler(plugin.value!, f)) } if (spec && spec.domEventObservers) for (let type in spec.domEventObservers) { let f = spec.domEventObservers[type] if (f) record(type).observers.push(bindHandler(plugin.value!, f)) } } for (let type in handlers) record(type).handlers.push(handlers[type]) for (let type in observers) record(type).observers.push(observers[type]) return result } const PendingKeys = [ {key: "Backspace", keyCode: 8, inputType: "deleteContentBackward"}, {key: "Enter", keyCode: 13, inputType: "insertParagraph"}, {key: "Enter", keyCode: 13, inputType: "insertLineBreak"}, {key: "Delete", keyCode: 46, inputType: "deleteContentForward"} ] const EmacsyPendingKeys = "dthko" // Key codes for modifier keys export const modifierCodes = [16, 17, 18, 20, 91, 92, 224, 225] const dragScrollMargin = 6 /// Interface that objects registered with /// [`EditorView.mouseSelectionStyle`](#view.EditorView^mouseSelectionStyle) /// must conform to. export interface MouseSelectionStyle { /// Return a new selection for the mouse gesture that starts with /// the event that was originally given to the constructor, and ends /// with the event passed here. In case of a plain click, those may /// both be the `mousedown` event, in case of a drag gesture, the /// latest `mousemove` event will be passed. /// /// When `extend` is true, that means the new selection should, if /// possible, extend the start selection. If `multiple` is true, the /// new selection should be added to the original selection. get: (curEvent: MouseEvent, extend: boolean, multiple: boolean) => EditorSelection /// Called when the view is updated while the gesture is in /// progress. When the document changes, it may be necessary to map /// some data (like the original selection or start position) /// through the changes. /// /// This may return `true` to indicate that the `get` method should /// get queried again after the update, because something in the /// update could change its result. Be wary of infinite loops when /// using this (where `get` returns a new selection, which will /// trigger `update`, which schedules another `get` in response). update: (update: ViewUpdate) => boolean | void } export type MakeSelectionStyle = (view: EditorView, event: MouseEvent) => MouseSelectionStyle | null function dragScrollSpeed(dist: number) { return Math.max(0, dist) * 0.7 + 8 } function dist(a: MouseEvent, b: MouseEvent) { return Math.max(Math.abs(a.clientX - b.clientX), Math.abs(a.clientY - b.clientY)) } class MouseSelection { dragging: null | boolean extend: boolean multiple: boolean lastEvent: MouseEvent scrollParent: HTMLElement | null scrollSpeed = {x: 0, y: 0} scrolling = -1 atoms: readonly RangeSet[] constructor(private view: EditorView, private startEvent: MouseEvent, private style: MouseSelectionStyle, private mustSelect: boolean) { this.lastEvent = startEvent this.scrollParent = scrollableParent(view.contentDOM) this.atoms = view.state.facet(atomicRanges).map(f => f(view)) let doc = view.contentDOM.ownerDocument! doc.addEventListener("mousemove", this.move = this.move.bind(this)) doc.addEventListener("mouseup", this.up = this.up.bind(this)) this.extend = startEvent.shiftKey this.multiple = view.state.facet(EditorState.allowMultipleSelections) && addsSelectionRange(view, startEvent) this.dragging = isInPrimarySelection(view, startEvent) && getClickType(startEvent) == 1 ? null : false } start(event: MouseEvent) { // When clicking outside of the selection, immediately apply the // effect of starting the selection if (this.dragging === false) this.select(event) } move(event: MouseEvent) { if (event.buttons == 0) return this.destroy() if (this.dragging || this.dragging == null && dist(this.startEvent, event) < 10) return this.select(this.lastEvent = event) let sx = 0, sy = 0 let rect = this.scrollParent?.getBoundingClientRect() || {left: 0, top: 0, right: this.view.win.innerWidth, bottom: this.view.win.innerHeight} let margins = getScrollMargins(this.view) if (event.clientX - margins.left <= rect.left + dragScrollMargin) sx = -dragScrollSpeed(rect.left - event.clientX) else if (event.clientX + margins.right >= rect.right - dragScrollMargin) sx = dragScrollSpeed(event.clientX - rect.right) if (event.clientY - margins.top <= rect.top + dragScrollMargin) sy = -dragScrollSpeed(rect.top - event.clientY) else if (event.clientY + margins.bottom >= rect.bottom - dragScrollMargin) sy = dragScrollSpeed(event.clientY - rect.bottom) this.setScrollSpeed(sx, sy) } up(event: MouseEvent) { if (this.dragging == null) this.select(this.lastEvent) if (!this.dragging) event.preventDefault() this.destroy() } destroy() { this.setScrollSpeed(0, 0) let doc = this.view.contentDOM.ownerDocument! doc.removeEventListener("mousemove", this.move) doc.removeEventListener("mouseup", this.up) this.view.inputState.mouseSelection = this.view.inputState.draggedContent = null } setScrollSpeed(sx: number, sy: number) { this.scrollSpeed = {x: sx, y: sy} if (sx || sy) { if (this.scrolling < 0) this.scrolling = setInterval(() => this.scroll(), 50) } else if (this.scrolling > -1) { clearInterval(this.scrolling) this.scrolling = -1 } } scroll() { if (this.scrollParent) { this.scrollParent.scrollLeft += this.scrollSpeed.x this.scrollParent.scrollTop += this.scrollSpeed.y } else { this.view.win.scrollBy(this.scrollSpeed.x, this.scrollSpeed.y) } if (this.dragging === false) this.select(this.lastEvent) } skipAtoms(sel: EditorSelection) { let ranges = null for (let i = 0; i < sel.ranges.length; i++) { let range = sel.ranges[i], updated = null if (range.empty) { let pos = skipAtomicRanges(this.atoms, range.from, 0) if (pos != range.from) updated = EditorSelection.cursor(pos, -1) } else { let from = skipAtomicRanges(this.atoms, range.from, -1) let to = skipAtomicRanges(this.atoms, range.to, 1) if (from != range.from || to != range.to) updated = EditorSelection.range(range.from == range.anchor ? from : to, range.from == range.head ? from : to) } if (updated) { if (!ranges) ranges = sel.ranges.slice() ranges[i] = updated } } return ranges ? EditorSelection.create(ranges, sel.mainIndex) : sel } select(event: MouseEvent) { let {view} = this, selection = this.skipAtoms(this.style.get(event, this.extend, this.multiple)) if (this.mustSelect || !selection.eq(view.state.selection, this.dragging === false)) this.view.dispatch({ selection, userEvent: "select.pointer" }) this.mustSelect = false } update(update: ViewUpdate) { if (this.style.update(update)) setTimeout(() => this.select(this.lastEvent), 20) } } function addsSelectionRange(view: EditorView, event: MouseEvent) { let facet = view.state.facet(clickAddsSelectionRange) return facet.length ? facet[0](event) : browser.mac ? event.metaKey : event.ctrlKey } function dragMovesSelection(view: EditorView, event: MouseEvent) { let facet = view.state.facet(dragBehavior) return facet.length ? facet[0](event) : browser.mac ? !event.altKey : !event.ctrlKey } function isInPrimarySelection(view: EditorView, event: MouseEvent) { let {main} = view.state.selection if (main.empty) return false // On boundary clicks, check whether the coordinates are inside the // selection's client rectangles let sel = getSelection(view.root) if (!sel || sel.rangeCount == 0) return true let rects = sel.getRangeAt(0).getClientRects() for (let i = 0; i < rects.length; i++) { let rect = rects[i] if (rect.left <= event.clientX && rect.right >= event.clientX && rect.top <= event.clientY && rect.bottom >= event.clientY) return true } return false } function eventBelongsToEditor(view: EditorView, event: Event): boolean { if (!event.bubbles) return true if (event.defaultPrevented) return false for (let node: Node | null = event.target as Node, cView; node != view.contentDOM; node = node.parentNode) if (!node || node.nodeType == 11 || ((cView = ContentView.get(node)) && cView.ignoreEvent(event))) return false return true } const handlers: {[key: string]: (view: EditorView, event: any) => boolean} = Object.create(null) const observers: {[key: string]: (view: EditorView, event: any) => undefined} = Object.create(null) // This is very crude, but unfortunately both these browsers _pretend_ // that they have a clipboard API—all the objects and methods are // there, they just don't work, and they are hard to test. const brokenClipboardAPI = (browser.ie && browser.ie_version < 15) || (browser.ios && browser.webkit_version < 604) function capturePaste(view: EditorView) { let parent = view.dom.parentNode if (!parent) return let target = parent.appendChild(document.createElement("textarea")) target.style.cssText = "position: fixed; left: -10000px; top: 10px" target.focus() setTimeout(() => { view.focus() target.remove() doPaste(view, target.value) }, 50) } function doPaste(view: EditorView, input: string) { let {state} = view, changes, i = 1, text = state.toText(input) let byLine = text.lines == state.selection.ranges.length let linewise = lastLinewiseCopy != null && state.selection.ranges.every(r => r.empty) && lastLinewiseCopy == text.toString() if (linewise) { let lastLine = -1 changes = state.changeByRange(range => { let line = state.doc.lineAt(range.from) if (line.from == lastLine) return {range} lastLine = line.from let insert = state.toText((byLine ? text.line(i++).text : input) + state.lineBreak) return {changes: {from: line.from, insert}, range: EditorSelection.cursor(range.from + insert.length)} }) } else if (byLine) { changes = state.changeByRange(range => { let line = text.line(i++) return {changes: {from: range.from, to: range.to, insert: line.text}, range: EditorSelection.cursor(range.from + line.length)} }) } else { changes = state.replaceSelection(text) } view.dispatch(changes, { userEvent: "input.paste", scrollIntoView: true }) } observers.scroll = view => { view.inputState.lastScrollTop = view.scrollDOM.scrollTop view.inputState.lastScrollLeft = view.scrollDOM.scrollLeft } handlers.keydown = (view, event: KeyboardEvent) => { view.inputState.setSelectionOrigin("select") if (event.keyCode == 27) view.inputState.lastEscPress = Date.now() return false } observers.touchstart = (view, e) => { view.inputState.lastTouchTime = Date.now() view.inputState.setSelectionOrigin("select.pointer") } observers.touchmove = view => { view.inputState.setSelectionOrigin("select.pointer") } handlers.mousedown = (view, event: MouseEvent) => { view.observer.flush() if (view.inputState.lastTouchTime > Date.now() - 2000) return false // Ignore touch interaction let style: MouseSelectionStyle | null = null for (let makeStyle of view.state.facet(mouseSelectionStyle)) { style = makeStyle(view, event) if (style) break } if (!style && event.button == 0) style = basicMouseSelection(view, event) if (style) { let mustFocus = !view.hasFocus view.inputState.startMouseSelection(new MouseSelection(view, event, style, mustFocus)) if (mustFocus) view.observer.ignore(() => focusPreventScroll(view.contentDOM)) let mouseSel = view.inputState.mouseSelection if (mouseSel) { mouseSel.start(event) return mouseSel.dragging === false } } return false } function rangeForClick(view: EditorView, pos: number, bias: -1 | 1, type: number): SelectionRange { if (type == 1) { // Single click return EditorSelection.cursor(pos, bias) } else if (type == 2) { // Double click return groupAt(view.state, pos, bias) } else { // Triple click let visual = LineView.find(view.docView, pos), line = view.state.doc.lineAt(visual ? visual.posAtEnd : pos) let from = visual ? visual.posAtStart : line.from, to = visual ? visual.posAtEnd : line.to if (to < view.state.doc.length && to == line.to) to++ return EditorSelection.range(from, to) } } let insideY = (y: number, rect: Rect) => y >= rect.top && y <= rect.bottom let inside = (x: number, y: number, rect: Rect) => insideY(y, rect) && x >= rect.left && x <= rect.right // Try to determine, for the given coordinates, associated with the // given position, whether they are related to the element before or // the element after the position. function findPositionSide(view: EditorView, pos: number, x: number, y: number) { let line = LineView.find(view.docView, pos) if (!line) return 1 let off = pos - line.posAtStart // Line boundaries point into the line if (off == 0) return 1 if (off == line.length) return -1 // Positions on top of an element point at that element let before = line.coordsAt(off, -1) if (before && inside(x, y, before)) return -1 let after = line.coordsAt(off, 1) if (after && inside(x, y, after)) return 1 // This is probably a line wrap point. Pick before if the point is // beside it. return before && insideY(y, before) ? -1 : 1 } function queryPos(view: EditorView, event: MouseEvent): {pos: number, bias: 1 | -1} { let pos = view.posAtCoords({x: event.clientX, y: event.clientY}, false) return {pos, bias: findPositionSide(view, pos, event.clientX, event.clientY)} } const BadMouseDetail = browser.ie && browser.ie_version <= 11 let lastMouseDown: MouseEvent | null = null, lastMouseDownCount = 0, lastMouseDownTime = 0 function getClickType(event: MouseEvent) { if (!BadMouseDetail) return event.detail let last = lastMouseDown, lastTime = lastMouseDownTime lastMouseDown = event lastMouseDownTime = Date.now() return lastMouseDownCount = !last || (lastTime > Date.now() - 400 && Math.abs(last.clientX - event.clientX) < 2 && Math.abs(last.clientY - event.clientY) < 2) ? (lastMouseDownCount + 1) % 3 : 1 } function basicMouseSelection(view: EditorView, event: MouseEvent) { let start = queryPos(view, event), type = getClickType(event) let startSel = view.state.selection return { update(update) { if (update.docChanged) { start.pos = update.changes.mapPos(start.pos) startSel = startSel.map(update.changes) } }, get(event, extend, multiple) { let cur = queryPos(view, event), removed let range = rangeForClick(view, cur.pos, cur.bias, type) if (start.pos != cur.pos && !extend) { let startRange = rangeForClick(view, start.pos, start.bias, type) let from = Math.min(startRange.from, range.from), to = Math.max(startRange.to, range.to) range = from < range.from ? EditorSelection.range(from, to) : EditorSelection.range(to, from) } if (extend) return startSel.replaceRange(startSel.main.extend(range.from, range.to)) else if (multiple && type == 1 && startSel.ranges.length > 1 && (removed = removeRangeAround(startSel, cur.pos))) return removed else if (multiple) return startSel.addRange(range) else return EditorSelection.create([range]) } } as MouseSelectionStyle } function removeRangeAround(sel: EditorSelection, pos: number) { for (let i = 0; i < sel.ranges.length; i++) { let {from, to} = sel.ranges[i] if (from <= pos && to >= pos) return EditorSelection.create(sel.ranges.slice(0, i).concat(sel.ranges.slice(i + 1)), sel.mainIndex == i ? 0 : sel.mainIndex - (sel.mainIndex > i ? 1 : 0)) } return null } handlers.dragstart = (view, event: DragEvent) => { let {selection: {main: range}} = view.state if ((event.target as HTMLElement).draggable) { let cView = view.docView.nearest(event.target as HTMLElement) if (cView && cView.isWidget) { let from = cView.posAtStart, to = from + cView.length if (from >= range.to || to <= range.from) range = EditorSelection.range(from, to) } } let {inputState} = view if (inputState.mouseSelection) inputState.mouseSelection.dragging = true inputState.draggedContent = range if (event.dataTransfer) { event.dataTransfer.setData("Text", view.state.sliceDoc(range.from, range.to)) event.dataTransfer.effectAllowed = "copyMove" } return false } handlers.dragend = view => { view.inputState.draggedContent = null return false } function dropText(view: EditorView, event: DragEvent, text: string, direct: boolean) { if (!text) return let dropPos = view.posAtCoords({x: event.clientX, y: event.clientY}, false) let {draggedContent} = view.inputState let del = direct && draggedContent && dragMovesSelection(view, event) ? {from: draggedContent.from, to: draggedContent.to} : null let ins = {from: dropPos, insert: text} let changes = view.state.changes(del ? [del, ins] : ins) view.focus() view.dispatch({ changes, selection: {anchor: changes.mapPos(dropPos, -1), head: changes.mapPos(dropPos, 1)}, userEvent: del ? "move.drop" : "input.drop" }) view.inputState.draggedContent = null } handlers.drop = (view, event: DragEvent) => { if (!event.dataTransfer) return false if (view.state.readOnly) return true let files = event.dataTransfer.files if (files && files.length) { // For a file drop, read the file's text. let text = Array(files.length), read = 0 let finishFile = () => { if (++read == files.length) dropText(view, event, text.filter(s => s != null).join(view.state.lineBreak), false) } for (let i = 0; i < files.length; i++) { let reader = new FileReader reader.onerror = finishFile reader.onload = () => { if (!/[\x00-\x08\x0e-\x1f]{2}/.test(reader.result as string)) text[i] = reader.result finishFile() } reader.readAsText(files[i]) } return true } else { let text = event.dataTransfer.getData("Text") if (text) { dropText(view, event, text, true) return true } } return false } handlers.paste = (view: EditorView, event: ClipboardEvent) => { if (view.state.readOnly) return true view.observer.flush() let data = brokenClipboardAPI ? null : event.clipboardData if (data) { doPaste(view, data.getData("text/plain") || data.getData("text/uri-list")) return true } else { capturePaste(view) return false } } function captureCopy(view: EditorView, text: string) { // The extra wrapper is somehow necessary on IE/Edge to prevent the // content from being mangled when it is put onto the clipboard let parent = view.dom.parentNode if (!parent) return let target = parent.appendChild(document.createElement("textarea")) target.style.cssText = "position: fixed; left: -10000px; top: 10px" target.value = text target.focus() target.selectionEnd = text.length target.selectionStart = 0 setTimeout(() => { target.remove() view.focus() }, 50) } function copiedRange(state: EditorState) { let content = [], ranges: {from: number, to: number}[] = [], linewise = false for (let range of state.selection.ranges) if (!range.empty) { content.push(state.sliceDoc(range.from, range.to)) ranges.push(range) } if (!content.length) { // Nothing selected, do a line-wise copy let upto = -1 for (let {from} of state.selection.ranges) { let line = state.doc.lineAt(from) if (line.number > upto) { content.push(line.text) ranges.push({from: line.from, to: Math.min(state.doc.length, line.to + 1)}) } upto = line.number } linewise = true } return {text: content.join(state.lineBreak), ranges, linewise} } let lastLinewiseCopy: string | null = null handlers.copy = handlers.cut = (view, event: ClipboardEvent) => { let {text, ranges, linewise} = copiedRange(view.state) if (!text && !linewise) return false lastLinewiseCopy = linewise ? text : null if (event.type == "cut" && !view.state.readOnly) view.dispatch({ changes: ranges, scrollIntoView: true, userEvent: "delete.cut" }) let data = brokenClipboardAPI ? null : event.clipboardData if (data) { data.clearData() data.setData("text/plain", text) return true } else { captureCopy(view, text) return false } } export const isFocusChange = Annotation.define() export function focusChangeTransaction(state: EditorState, focus: boolean) { let effects = [] for (let getEffect of state.facet(focusChangeEffect)) { let effect = getEffect(state, focus) if (effect) effects.push(effect) } return effects ? state.update({effects, annotations: isFocusChange.of(true)}) : null } function updateForFocusChange(view: EditorView) { setTimeout(() => { let focus = view.hasFocus if (focus != view.inputState.notifiedFocused) { let tr = focusChangeTransaction(view.state, focus) if (tr) view.dispatch(tr) else view.update([]) } }, 10) } observers.focus = view => { view.inputState.lastFocusTime = Date.now() // When focusing reset the scroll position, move it back to where it was if (!view.scrollDOM.scrollTop && (view.inputState.lastScrollTop || view.inputState.lastScrollLeft)) { view.scrollDOM.scrollTop = view.inputState.lastScrollTop view.scrollDOM.scrollLeft = view.inputState.lastScrollLeft } updateForFocusChange(view) } observers.blur = view => { view.observer.clearSelectionRange() updateForFocusChange(view) } observers.compositionstart = observers.compositionupdate = view => { if (view.inputState.compositionFirstChange == null) view.inputState.compositionFirstChange = true if (view.inputState.composing < 0) { // FIXME possibly set a timeout to clear it again on Android view.inputState.composing = 0 } } observers.compositionend = view => { view.inputState.composing = -1 view.inputState.compositionEndedAt = Date.now() view.inputState.compositionPendingKey = true view.inputState.compositionPendingChange = view.observer.pendingRecords().length > 0 view.inputState.compositionFirstChange = null if (browser.chrome && browser.android) { // Delay flushing for a bit on Android because it'll often fire a // bunch of contradictory changes in a row at end of compositon view.observer.flushSoon() } else if (view.inputState.compositionPendingChange) { // If we found pending records, schedule a flush. Promise.resolve().then(() => view.observer.flush()) } else { // Otherwise, make sure that, if no changes come in soon, the // composition view is cleared. setTimeout(() => { if (view.inputState.composing < 0 && view.docView.hasComposition) view.update([]) }, 50) } } observers.contextmenu = view => { view.inputState.lastContextMenu = Date.now() } handlers.beforeinput = (view, event) => { // Because Chrome Android doesn't fire useful key events, use // beforeinput to detect backspace (and possibly enter and delete, // but those usually don't even seem to fire beforeinput events at // the moment) and fake a key event for it. // // (preventDefault on beforeinput, though supported in the spec, // seems to do nothing at all on Chrome). let pending if (browser.chrome && browser.android && (pending = PendingKeys.find(key => key.inputType == event.inputType))) { view.observer.delayAndroidKey(pending.key, pending.keyCode) if (pending.key == "Backspace" || pending.key == "Delete") { let startViewHeight = window.visualViewport?.height || 0 setTimeout(() => { // Backspacing near uneditable nodes on Chrome Android sometimes // closes the virtual keyboard. This tries to crudely detect // that and refocus to get it back. if ((window.visualViewport?.height || 0) > startViewHeight + 10 && view.hasFocus) { view.contentDOM.blur() view.focus() } }, 100) } } if (browser.ios && event.inputType == "deleteContentForward") { // For some reason, DOM changes (and beforeinput) happen _before_ // the key event for ctrl-d on iOS when using an external // keyboard. view.observer.flushSoon() } // Safari will occasionally forget to fire compositionend at the end of a dead-key composition if (browser.safari && event.inputType == "insertText" && view.inputState.composing >= 0) { setTimeout(() => observers.compositionend(view, event), 20) } return false } const appliedFirefoxHack: Set = new Set // In Firefox, when cut/copy handlers are added to the document, that // somehow avoids a bug where those events aren't fired when the // selection is empty. See https://github.com/codemirror/dev/issues/1082 // and https://bugzilla.mozilla.org/show_bug.cgi?id=995961 function firefoxCopyCutHack(doc: Document) { if (!appliedFirefoxHack.has(doc)) { appliedFirefoxHack.add(doc) doc.addEventListener("copy", () => {}) doc.addEventListener("cut", () => {}) } } view-6.26.3/src/keymap.ts000066400000000000000000000252041460616253600152230ustar00rootroot00000000000000import {EditorView} from "./editorview" import {Command} from "./extension" import {modifierCodes} from "./input" import {base, shift, keyName} from "w3c-keyname" import {Facet, Prec, EditorState, codePointSize, codePointAt} from "@codemirror/state" import browser from "./browser" /// Key bindings associate key names with /// [command](#view.Command)-style functions. /// /// Key names may be strings like `"Shift-Ctrl-Enter"`—a key identifier /// prefixed with zero or more modifiers. Key identifiers are based on /// the strings that can appear in /// [`KeyEvent.key`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key). /// Use lowercase letters to refer to letter keys (or uppercase letters /// if you want shift to be held). You may use `"Space"` as an alias /// for the `" "` name. /// /// Modifiers can be given in any order. `Shift-` (or `s-`), `Alt-` (or /// `a-`), `Ctrl-` (or `c-` or `Control-`) and `Cmd-` (or `m-` or /// `Meta-`) are recognized. /// /// When a key binding contains multiple key names separated by /// spaces, it represents a multi-stroke binding, which will fire when /// the user presses the given keys after each other. /// /// You can use `Mod-` as a shorthand for `Cmd-` on Mac and `Ctrl-` on /// other platforms. So `Mod-b` is `Ctrl-b` on Linux but `Cmd-b` on /// macOS. export interface KeyBinding { /// The key name to use for this binding. If the platform-specific /// property (`mac`, `win`, or `linux`) for the current platform is /// used as well in the binding, that one takes precedence. If `key` /// isn't defined and the platform-specific binding isn't either, /// a binding is ignored. key?: string, /// Key to use specifically on macOS. mac?: string, /// Key to use specifically on Windows. win?: string, /// Key to use specifically on Linux. linux?: string, /// The command to execute when this binding is triggered. When the /// command function returns `false`, further bindings will be tried /// for the key. run?: Command, /// When given, this defines a second binding, using the (possibly /// platform-specific) key name prefixed with `Shift-` to activate /// this command. shift?: Command /// When this property is present, the function is called for every /// key that is not a multi-stroke prefix. any?: (view: EditorView, event: KeyboardEvent) => boolean /// By default, key bindings apply when focus is on the editor /// content (the `"editor"` scope). Some extensions, mostly those /// that define their own panels, might want to allow you to /// register bindings local to that panel. Such bindings should use /// a custom scope name. You may also assign multiple scope names to /// a binding, separating them by spaces. scope?: string /// When set to true (the default is false), this will always /// prevent the further handling for the bound key, even if the /// command(s) return false. This can be useful for cases where the /// native behavior of the key is annoying or irrelevant but the /// command doesn't always apply (such as, Mod-u for undo selection, /// which would cause the browser to view source instead when no /// selection can be undone). preventDefault?: boolean /// When set to true, `stopPropagation` will be called on keyboard /// events that have their `preventDefault` called in response to /// this key binding (see also /// [`preventDefault`](#view.KeyBinding.preventDefault)). stopPropagation?: boolean } type PlatformName = "mac" | "win" | "linux" | "key" const currentPlatform: PlatformName = browser.mac ? "mac" : browser.windows ? "win" : browser.linux ? "linux" : "key" function normalizeKeyName(name: string, platform: PlatformName): string { const parts = name.split(/-(?!$)/) let result = parts[parts.length - 1] if (result == "Space") result = " " let alt, ctrl, shift, meta for (let i = 0; i < parts.length - 1; ++i) { const mod = parts[i] if (/^(cmd|meta|m)$/i.test(mod)) meta = true else if (/^a(lt)?$/i.test(mod)) alt = true else if (/^(c|ctrl|control)$/i.test(mod)) ctrl = true else if (/^s(hift)?$/i.test(mod)) shift = true else if (/^mod$/i.test(mod)) { if (platform == "mac") meta = true; else ctrl = true } else throw new Error("Unrecognized modifier name: " + mod) } if (alt) result = "Alt-" + result if (ctrl) result = "Ctrl-" + result if (meta) result = "Meta-" + result if (shift) result = "Shift-" + result return result } function modifiers(name: string, event: KeyboardEvent, shift: boolean) { if (event.altKey) name = "Alt-" + name if (event.ctrlKey) name = "Ctrl-" + name if (event.metaKey) name = "Meta-" + name if (shift !== false && event.shiftKey) name = "Shift-" + name return name } type Binding = { preventDefault: boolean, stopPropagation: boolean, run: ((view: EditorView, event: KeyboardEvent) => boolean)[] } // In each scope, the `_all` property is used for bindings that apply // to all keys. type Keymap = {[scope: string]: {[key: string]: Binding}} const handleKeyEvents = Prec.default(EditorView.domEventHandlers({ keydown(event, view) { return runHandlers(getKeymap(view.state), event, view, "editor") } })) /// Facet used for registering keymaps. /// /// You can add multiple keymaps to an editor. Their priorities /// determine their precedence (the ones specified early or with high /// priority get checked first). When a handler has returned `true` /// for a given key, no further handlers are called. export const keymap = Facet.define({enables: handleKeyEvents}) const Keymaps = new WeakMap() // This is hidden behind an indirection, rather than directly computed // by the facet, to keep internal types out of the facet's type. function getKeymap(state: EditorState) { let bindings = state.facet(keymap) let map = Keymaps.get(bindings) if (!map) Keymaps.set(bindings, map = buildKeymap(bindings.reduce((a, b) => a.concat(b), []))) return map } /// Run the key handlers registered for a given scope. The event /// object should be a `"keydown"` event. Returns true if any of the /// handlers handled it. export function runScopeHandlers(view: EditorView, event: KeyboardEvent, scope: string) { return runHandlers(getKeymap(view.state), event, view, scope) } let storedPrefix: {view: EditorView, prefix: string, scope: string} | null = null const PrefixTimeout = 4000 function buildKeymap(bindings: readonly KeyBinding[], platform = currentPlatform) { let bound: Keymap = Object.create(null) let isPrefix: {[prefix: string]: boolean} = Object.create(null) let checkPrefix = (name: string, is: boolean) => { let current = isPrefix[name] if (current == null) isPrefix[name] = is else if (current != is) throw new Error("Key binding " + name + " is used both as a regular binding and as a multi-stroke prefix") } let add = (scope: string, key: string, command: Command | undefined, preventDefault?: boolean, stopPropagation?: boolean) => { let scopeObj = bound[scope] || (bound[scope] = Object.create(null)) let parts = key.split(/ (?!$)/).map(k => normalizeKeyName(k, platform)) for (let i = 1; i < parts.length; i++) { let prefix = parts.slice(0, i).join(" ") checkPrefix(prefix, true) if (!scopeObj[prefix]) scopeObj[prefix] = { preventDefault: true, stopPropagation: false, run: [(view: EditorView) => { let ourObj = storedPrefix = {view, prefix, scope} setTimeout(() => { if (storedPrefix == ourObj) storedPrefix = null }, PrefixTimeout) return true }] } } let full = parts.join(" ") checkPrefix(full, false) let binding = scopeObj[full] || (scopeObj[full] = { preventDefault: false, stopPropagation: false, run: scopeObj._any?.run?.slice() || [] }) if (command) binding.run.push(command) if (preventDefault) binding.preventDefault = true if (stopPropagation) binding.stopPropagation = true } for (let b of bindings) { let scopes = b.scope ? b.scope.split(" ") : ["editor"] if (b.any) for (let scope of scopes) { let scopeObj = bound[scope] || (bound[scope] = Object.create(null)) if (!scopeObj._any) scopeObj._any = {preventDefault: false, stopPropagation: false, run: []} for (let key in scopeObj) scopeObj[key].run.push(b.any) } let name = b[platform] || b.key if (!name) continue for (let scope of scopes) { add(scope, name, b.run, b.preventDefault, b.stopPropagation) if (b.shift) add(scope, "Shift-" + name, b.shift, b.preventDefault, b.stopPropagation) } } return bound } function runHandlers(map: Keymap, event: KeyboardEvent, view: EditorView, scope: string): boolean { let name = keyName(event) let charCode = codePointAt(name, 0), isChar = codePointSize(charCode) == name.length && name != " " let prefix = "", handled = false, prevented = false, stopPropagation = false if (storedPrefix && storedPrefix.view == view && storedPrefix.scope == scope) { prefix = storedPrefix.prefix + " " if (modifierCodes.indexOf(event.keyCode) < 0) { prevented = true storedPrefix = null } } let ran: Set<(view: EditorView, event: KeyboardEvent) => boolean> = new Set let runFor = (binding: Binding | undefined) => { if (binding) { for (let cmd of binding.run) if (!ran.has(cmd)) { ran.add(cmd) if (cmd(view, event)) { if (binding.stopPropagation) stopPropagation = true return true } } if (binding.preventDefault) { if (binding.stopPropagation) stopPropagation = true prevented = true } } return false } let scopeObj = map[scope], baseName, shiftName if (scopeObj) { if (runFor(scopeObj[prefix + modifiers(name, event, !isChar)])) { handled = true } else if (isChar && (event.altKey || event.metaKey || event.ctrlKey) && // Ctrl-Alt may be used for AltGr on Windows !(browser.windows && event.ctrlKey && event.altKey) && (baseName = base[event.keyCode]) && baseName != name) { if (runFor(scopeObj[prefix + modifiers(baseName, event, true)])) { handled = true } else if (event.shiftKey && (shiftName = shift[event.keyCode]) != name && shiftName != baseName && runFor(scopeObj[prefix + modifiers(shiftName, event, false)])) { handled = true } } else if (isChar && event.shiftKey && runFor(scopeObj[prefix + modifiers(name, event, true)])) { handled = true } if (!handled && runFor(scopeObj._any)) handled = true } if (prevented) handled = true if (handled && stopPropagation) event.stopPropagation() return handled } view-6.26.3/src/layer.ts000066400000000000000000000311611460616253600150500ustar00rootroot00000000000000import {Extension, Facet, EditorState, EditorSelection, SelectionRange} from "@codemirror/state" import {ViewPlugin, ViewUpdate} from "./extension" import {EditorView} from "./editorview" import {Direction} from "./bidi" import {BlockType} from "./decoration" import {BlockInfo} from "./heightmap" import {blockAt} from "./cursor" /// Markers shown in a [layer](#view.layer) must conform to this /// interface. They are created in a measuring phase, and have to /// contain all their positioning information, so that they can be /// drawn without further DOM layout reading. /// /// Markers are automatically absolutely positioned. Their parent /// element has the same top-left corner as the document, so they /// should be positioned relative to the document. export interface LayerMarker { /// Compare this marker to a marker of the same type. Used to avoid /// unnecessary redraws. eq(other: LayerMarker): boolean /// Draw the marker to the DOM. draw(): HTMLElement /// Update an existing marker of this type to this marker. update?(dom: HTMLElement, oldMarker: LayerMarker): boolean } /// Implementation of [`LayerMarker`](#view.LayerMarker) that creates /// a rectangle at a given set of coordinates. export class RectangleMarker implements LayerMarker { /// Create a marker with the given class and dimensions. If `width` /// is null, the DOM element will get no width style. constructor(private className: string, /// The left position of the marker (in pixels, document-relative). readonly left: number, /// The top position of the marker. readonly top: number, /// The width of the marker, or null if it shouldn't get a width assigned. readonly width: number | null, /// The height of the marker. readonly height: number) {} draw() { let elt = document.createElement("div") elt.className = this.className this.adjust(elt) return elt } update(elt: HTMLElement, prev: RectangleMarker) { if (prev.className != this.className) return false this.adjust(elt) return true } private adjust(elt: HTMLElement) { elt.style.left = this.left + "px" elt.style.top = this.top + "px" if (this.width != null) elt.style.width = this.width + "px" elt.style.height = this.height + "px" } eq(p: RectangleMarker) { return this.left == p.left && this.top == p.top && this.width == p.width && this.height == p.height && this.className == p.className } /// Create a set of rectangles for the given selection range, /// assigning them theclass`className`. Will create a single /// rectangle for empty ranges, and a set of selection-style /// rectangles covering the range's content (in a bidi-aware /// way) for non-empty ones. static forRange(view: EditorView, className: string, range: SelectionRange): readonly RectangleMarker[] { if (range.empty) { let pos = view.coordsAtPos(range.head, range.assoc || 1) if (!pos) return [] let base = getBase(view) return [new RectangleMarker(className, pos.left - base.left, pos.top - base.top, null, pos.bottom - pos.top)] } else { return rectanglesForRange(view, className, range) } } } function getBase(view: EditorView) { let rect = view.scrollDOM.getBoundingClientRect() let left = view.textDirection == Direction.LTR ? rect.left : rect.right - view.scrollDOM.clientWidth * view.scaleX return {left: left - view.scrollDOM.scrollLeft * view.scaleX, top: rect.top - view.scrollDOM.scrollTop * view.scaleY} } function wrappedLine(view: EditorView, pos: number, inside: {from: number, to: number}) { let range = EditorSelection.cursor(pos) return {from: Math.max(inside.from, view.moveToLineBoundary(range, false, true).from), to: Math.min(inside.to, view.moveToLineBoundary(range, true, true).from), type: BlockType.Text} } // Added to range rectangle's vertical extent to prevent rounding // errors from introducing gaps in the rendered content. const enum C { Epsilon = 0.01 } function rectanglesForRange(view: EditorView, className: string, range: SelectionRange): RectangleMarker[] { if (range.to <= view.viewport.from || range.from >= view.viewport.to) return [] let from = Math.max(range.from, view.viewport.from), to = Math.min(range.to, view.viewport.to) let ltr = view.textDirection == Direction.LTR let content = view.contentDOM, contentRect = content.getBoundingClientRect(), base = getBase(view) let lineElt = content.querySelector(".cm-line"), lineStyle = lineElt && window.getComputedStyle(lineElt) let leftSide = contentRect.left + (lineStyle ? parseInt(lineStyle.paddingLeft) + Math.min(0, parseInt(lineStyle.textIndent)) : 0) let rightSide = contentRect.right - (lineStyle ? parseInt(lineStyle.paddingRight) : 0) let startBlock = blockAt(view, from), endBlock = blockAt(view, to) let visualStart: {from: number, to: number} | null = startBlock.type == BlockType.Text ? startBlock : null let visualEnd: {from: number, to: number} | null = endBlock.type == BlockType.Text ? endBlock : null if (visualStart && (view.lineWrapping || startBlock.widgetLineBreaks)) visualStart = wrappedLine(view, from, visualStart) if (visualEnd && (view.lineWrapping || endBlock.widgetLineBreaks)) visualEnd = wrappedLine(view, to, visualEnd) if (visualStart && visualEnd && visualStart.from == visualEnd.from) { return pieces(drawForLine(range.from, range.to, visualStart)) } else { let top = visualStart ? drawForLine(range.from, null, visualStart) : drawForWidget(startBlock, false) let bottom = visualEnd ? drawForLine(null, range.to, visualEnd) : drawForWidget(endBlock, true) let between = [] if ((visualStart || startBlock).to < (visualEnd || endBlock).from - (visualStart && visualEnd ? 1 : 0) || startBlock.widgetLineBreaks > 1 && top.bottom + view.defaultLineHeight / 2 < bottom.top) between.push(piece(leftSide, top.bottom, rightSide, bottom.top)) else if (top.bottom < bottom.top && view.elementAtHeight((top.bottom + bottom.top) / 2).type == BlockType.Text) top.bottom = bottom.top = (top.bottom + bottom.top) / 2 return pieces(top).concat(between).concat(pieces(bottom)) } function piece(left: number, top: number, right: number, bottom: number) { return new RectangleMarker(className, left - base.left, top - base.top - C.Epsilon, right - left, bottom - top + C.Epsilon) } function pieces({top, bottom, horizontal}: {top: number, bottom: number, horizontal: number[]}) { let pieces = [] for (let i = 0; i < horizontal.length; i += 2) pieces.push(piece(horizontal[i], top, horizontal[i + 1], bottom)) return pieces } // Gets passed from/to in line-local positions function drawForLine(from: null | number, to: null | number, line: {from: number, to: number}) { let top = 1e9, bottom = -1e9, horizontal: number[] = [] function addSpan(from: number, fromOpen: boolean, to: number, toOpen: boolean, dir: Direction) { // Passing 2/-2 is a kludge to force the view to return // coordinates on the proper side of block widgets, since // normalizing the side there, though appropriate for most // coordsAtPos queries, would break selection drawing. let fromCoords = view.coordsAtPos(from, (from == line.to ? -2 : 2) as any) let toCoords = view.coordsAtPos(to, (to == line.from ? 2 : -2) as any) if (!fromCoords || !toCoords) return top = Math.min(fromCoords.top, toCoords.top, top) bottom = Math.max(fromCoords.bottom, toCoords.bottom, bottom) if (dir == Direction.LTR) horizontal.push(ltr && fromOpen ? leftSide : fromCoords.left, ltr && toOpen ? rightSide : toCoords.right) else horizontal.push(!ltr && toOpen ? leftSide : toCoords.left, !ltr && fromOpen ? rightSide : fromCoords.right) } let start = from ?? line.from, end = to ?? line.to // Split the range by visible range and document line for (let r of view.visibleRanges) if (r.to > start && r.from < end) { for (let pos = Math.max(r.from, start), endPos = Math.min(r.to, end);;) { let docLine = view.state.doc.lineAt(pos) for (let span of view.bidiSpans(docLine)) { let spanFrom = span.from + docLine.from, spanTo = span.to + docLine.from if (spanFrom >= endPos) break if (spanTo > pos) addSpan(Math.max(spanFrom, pos), from == null && spanFrom <= start, Math.min(spanTo, endPos), to == null && spanTo >= end, span.dir) } pos = docLine.to + 1 if (pos >= endPos) break } } if (horizontal.length == 0) addSpan(start, from == null, end, to == null, view.textDirection) return {top, bottom, horizontal} } function drawForWidget(block: BlockInfo, top: boolean) { let y = contentRect.top + (top ? block.top : block.bottom) return {top: y, bottom: y, horizontal: []} } } interface LayerConfig { /// Determines whether this layer is shown above or below the text. above: boolean, /// When given, this class is added to the DOM element that will /// wrap the markers. class?: string /// Called on every view update. Returning true triggers a marker /// update (a call to `markers` and drawing of those markers). update(update: ViewUpdate, layer: HTMLElement): boolean /// Whether to update this layer every time the document view /// changes. Defaults to true. updateOnDocViewUpdate?: boolean /// Build a set of markers for this layer, and measure their /// dimensions. markers(view: EditorView): readonly LayerMarker[] /// If given, this is called when the layer is created. mount?(layer: HTMLElement, view: EditorView): void /// If given, called when the layer is removed from the editor or /// the entire editor is destroyed. destroy?(layer: HTMLElement, view: EditorView): void } function sameMarker(a: LayerMarker, b: LayerMarker) { return a.constructor == b.constructor && a.eq(b) } class LayerView { measureReq: {read: () => readonly LayerMarker[], write: (markers: readonly LayerMarker[]) => void} dom: HTMLElement drawn: readonly LayerMarker[] = [] scaleX = 1 scaleY = 1 constructor(readonly view: EditorView, readonly layer: LayerConfig) { this.measureReq = {read: this.measure.bind(this), write: this.draw.bind(this)} this.dom = view.scrollDOM.appendChild(document.createElement("div")) this.dom.classList.add("cm-layer") if (layer.above) this.dom.classList.add("cm-layer-above") if (layer.class) this.dom.classList.add(layer.class) this.scale() this.dom.setAttribute("aria-hidden", "true") this.setOrder(view.state) view.requestMeasure(this.measureReq) if (layer.mount) layer.mount(this.dom, view) } update(update: ViewUpdate) { if (update.startState.facet(layerOrder) != update.state.facet(layerOrder)) this.setOrder(update.state) if (this.layer.update(update, this.dom) || update.geometryChanged) { this.scale() update.view.requestMeasure(this.measureReq) } } docViewUpdate(view: EditorView) { if (this.layer.updateOnDocViewUpdate !== false) view.requestMeasure(this.measureReq) } setOrder(state: EditorState) { let pos = 0, order = state.facet(layerOrder) while (pos < order.length && order[pos] != this.layer) pos++ this.dom.style.zIndex = String((this.layer.above ? 150 : -1) - pos) } measure(): readonly LayerMarker[] { return this.layer.markers(this.view) } scale() { let {scaleX, scaleY} = this.view if (scaleX != this.scaleX || scaleY != this.scaleY) { this.scaleX = scaleX; this.scaleY = scaleY this.dom.style.transform = `scale(${1 / scaleX}, ${1 / scaleY})` } } draw(markers: readonly LayerMarker[]) { if (markers.length != this.drawn.length || markers.some((p, i) => !sameMarker(p, this.drawn[i]))) { let old = this.dom.firstChild, oldI = 0 for (let marker of markers) { if (marker.update && old && marker.constructor && this.drawn[oldI].constructor && marker.update(old as HTMLElement, this.drawn[oldI])) { old = old.nextSibling oldI++ } else { this.dom.insertBefore(marker.draw(), old) } } while (old) { let next = old.nextSibling old.remove() old = next } this.drawn = markers } } destroy() { if (this.layer.destroy) this.layer.destroy(this.dom, this.view) this.dom.remove() } } const layerOrder = Facet.define() /// Define a layer. export function layer(config: LayerConfig): Extension { return [ ViewPlugin.define(v => new LayerView(v, config)), layerOrder.of(config) ] } view-6.26.3/src/matchdecorator.ts000066400000000000000000000153671460616253600167450ustar00rootroot00000000000000import {Text, RangeSetBuilder, Range} from "@codemirror/state" import {EditorView} from "./editorview" import {ViewUpdate} from "./extension" import {Decoration, DecorationSet} from "./decoration" function iterMatches(doc: Text, re: RegExp, from: number, to: number, f: (from: number, m: RegExpExecArray) => void) { re.lastIndex = 0 for (let cursor = doc.iterRange(from, to), pos = from, m; !cursor.next().done; pos += cursor.value.length) { if (!cursor.lineBreak) while (m = re.exec(cursor.value)) f(pos + m.index, m) } } function matchRanges(view: EditorView, maxLength: number) { let visible = view.visibleRanges if (visible.length == 1 && visible[0].from == view.viewport.from && visible[0].to == view.viewport.to) return visible let result = [] for (let {from, to} of visible) { from = Math.max(view.state.doc.lineAt(from).from, from - maxLength) to = Math.min(view.state.doc.lineAt(to).to, to + maxLength) if (result.length && result[result.length - 1].to >= from) result[result.length - 1].to = to else result.push({from, to}) } return result } /// Helper class used to make it easier to maintain decorations on /// visible code that matches a given regular expression. To be used /// in a [view plugin](#view.ViewPlugin). Instances of this object /// represent a matching configuration. export class MatchDecorator { private regexp: RegExp private addMatch: (match: RegExpExecArray, view: EditorView, from: number, add: (from: number, to: number, deco: Decoration) => void) => void private boundary: RegExp | undefined private maxLength: number /// Create a decorator. constructor(config: { /// The regular expression to match against the content. Will only /// be matched inside lines (not across them). Should have its 'g' /// flag set. regexp: RegExp, /// The decoration to apply to matches, either directly or as a /// function of the match. decoration?: Decoration | ((match: RegExpExecArray, view: EditorView, pos: number) => Decoration | null), /// Customize the way decorations are added for matches. This /// function, when given, will be called for matches and should /// call `add` to create decorations for them. Note that the /// decorations should appear *in* the given range, and the /// function should have no side effects beyond calling `add`. /// /// The `decoration` option is ignored when `decorate` is /// provided. decorate?: (add: (from: number, to: number, decoration: Decoration) => void, from: number, to: number, match: RegExpExecArray, view: EditorView) => void, /// By default, changed lines are re-matched entirely. You can /// provide a boundary expression, which should match single /// character strings that can never occur in `regexp`, to reduce /// the amount of re-matching. boundary?: RegExp, /// Matching happens by line, by default, but when lines are /// folded or very long lines are only partially drawn, the /// decorator may avoid matching part of them for speed. This /// controls how much additional invisible content it should /// include in its matches. Defaults to 1000. maxLength?: number, }) { const {regexp, decoration, decorate, boundary, maxLength = 1000} = config if (!regexp.global) throw new RangeError("The regular expression given to MatchDecorator should have its 'g' flag set") this.regexp = regexp if (decorate) { this.addMatch = (match, view, from, add) => decorate(add, from, from + match[0].length, match, view) } else if (typeof decoration == "function") { this.addMatch = (match, view, from, add) => { let deco = decoration(match, view, from) if (deco) add(from, from + match[0].length, deco) } } else if (decoration) { this.addMatch = (match, _view, from, add) => add(from, from + match[0].length, decoration) } else { throw new RangeError("Either 'decorate' or 'decoration' should be provided to MatchDecorator") } this.boundary = boundary this.maxLength = maxLength } /// Compute the full set of decorations for matches in the given /// view's viewport. You'll want to call this when initializing your /// plugin. createDeco(view: EditorView) { let build = new RangeSetBuilder(), add = build.add.bind(build) for (let {from, to} of matchRanges(view, this.maxLength)) iterMatches(view.state.doc, this.regexp, from, to, (from, m) => this.addMatch(m, view, from, add)) return build.finish() } /// Update a set of decorations for a view update. `deco` _must_ be /// the set of decorations produced by _this_ `MatchDecorator` for /// the view state before the update. updateDeco(update: ViewUpdate, deco: DecorationSet) { let changeFrom = 1e9, changeTo = -1 if (update.docChanged) update.changes.iterChanges((_f, _t, from, to) => { if (to > update.view.viewport.from && from < update.view.viewport.to) { changeFrom = Math.min(from, changeFrom) changeTo = Math.max(to, changeTo) } }) if (update.viewportChanged || changeTo - changeFrom > 1000) return this.createDeco(update.view) if (changeTo > -1) return this.updateRange(update.view, deco.map(update.changes), changeFrom, changeTo) return deco } private updateRange(view: EditorView, deco: DecorationSet, updateFrom: number, updateTo: number) { for (let r of view.visibleRanges) { let from = Math.max(r.from, updateFrom), to = Math.min(r.to, updateTo) if (to > from) { let fromLine = view.state.doc.lineAt(from), toLine = fromLine.to < to ? view.state.doc.lineAt(to) : fromLine let start = Math.max(r.from, fromLine.from), end = Math.min(r.to, toLine.to) if (this.boundary) { for (; from > fromLine.from; from--) if (this.boundary.test(fromLine.text[from - 1 - fromLine.from])) { start = from break } for (; to < toLine.to; to++) if (this.boundary.test(toLine.text[to - toLine.from])) { end = to break } } let ranges: Range[] = [], m let add = (from: number, to: number, deco: Decoration) => ranges.push(deco.range(from, to)) if (fromLine == toLine) { this.regexp.lastIndex = start - fromLine.from while ((m = this.regexp.exec(fromLine.text)) && m.index < end - fromLine.from) this.addMatch(m, view, m.index + fromLine.from, add) } else { iterMatches(view.state.doc, this.regexp, start, end, (from, m) => this.addMatch(m, view, from, add)) } deco = deco.update({filterFrom: start, filterTo: end, filter: (from, to) => from < start || to > end, add: ranges}) } } return deco } } view-6.26.3/src/panel.ts000066400000000000000000000150441460616253600150350ustar00rootroot00000000000000import {Facet, Extension} from "@codemirror/state" import {EditorView} from "./editorview" import {ViewPlugin, ViewUpdate} from "./extension" type PanelConfig = { /// By default, panels will be placed inside the editor's DOM /// structure. You can use this option to override where panels with /// `top: true` are placed. topContainer?: HTMLElement /// Override where panels with `top: false` are placed. bottomContainer?: HTMLElement } const panelConfig = Facet.define({ combine(configs: readonly PanelConfig[]) { let topContainer, bottomContainer for (let c of configs) { topContainer = topContainer || c.topContainer bottomContainer = bottomContainer || c.bottomContainer } return {topContainer, bottomContainer} } }) /// Configures the panel-managing extension. export function panels(config?: PanelConfig): Extension { return config ? [panelConfig.of(config)] : [] } /// Object that describes an active panel. export interface Panel { /// The element representing this panel. The library will add the /// `"cm-panel"` DOM class to this. dom: HTMLElement, /// Optionally called after the panel has been added to the editor. mount?(): void /// Update the DOM for a given view update. update?(update: ViewUpdate): void /// Called when the panel is removed from the editor or the editor /// is destroyed. destroy?(): void /// Whether the panel should be at the top or bottom of the editor. /// Defaults to false. top?: boolean } /// Get the active panel created by the given constructor, if any. /// This can be useful when you need access to your panels' DOM /// structure. export function getPanel(view: EditorView, panel: PanelConstructor) { let plugin = view.plugin(panelPlugin) let index = plugin ? plugin.specs.indexOf(panel) : -1 return index > -1 ? plugin!.panels[index] : null } const panelPlugin = ViewPlugin.fromClass(class { input: readonly (null | PanelConstructor)[] specs: readonly PanelConstructor[] panels: Panel[] top: PanelGroup bottom: PanelGroup constructor(view: EditorView) { this.input = view.state.facet(showPanel) this.specs = this.input.filter(s => s) as PanelConstructor[] this.panels = this.specs.map(spec => spec(view)) let conf = view.state.facet(panelConfig) this.top = new PanelGroup(view, true, conf.topContainer) this.bottom = new PanelGroup(view, false, conf.bottomContainer) this.top.sync(this.panels.filter(p => p.top)) this.bottom.sync(this.panels.filter(p => !p.top)) for (let p of this.panels) { p.dom.classList.add("cm-panel") if (p.mount) p.mount() } } update(update: ViewUpdate) { let conf = update.state.facet(panelConfig) if (this.top.container != conf.topContainer) { this.top.sync([]) this.top = new PanelGroup(update.view, true, conf.topContainer) } if (this.bottom.container != conf.bottomContainer) { this.bottom.sync([]) this.bottom = new PanelGroup(update.view, false, conf.bottomContainer) } this.top.syncClasses() this.bottom.syncClasses() let input = update.state.facet(showPanel) if (input != this.input) { let specs = input.filter(x => x) as PanelConstructor[] let panels = [], top: Panel[] = [], bottom: Panel[] = [], mount = [] for (let spec of specs) { let known = this.specs.indexOf(spec), panel if (known < 0) { panel = spec(update.view) mount.push(panel) } else { panel = this.panels[known] if (panel.update) panel.update(update) } panels.push(panel) ;(panel.top ? top : bottom).push(panel) } this.specs = specs this.panels = panels this.top.sync(top) this.bottom.sync(bottom) for (let p of mount) { p.dom.classList.add("cm-panel") if (p.mount) p.mount!() } } else { for (let p of this.panels) if (p.update) p.update(update) } } destroy() { this.top.sync([]) this.bottom.sync([]) } }, { provide: plugin => EditorView.scrollMargins.of(view => { let value = view.plugin(plugin) return value && {top: value.top.scrollMargin(), bottom: value.bottom.scrollMargin()} }) }) class PanelGroup { dom: HTMLElement | undefined = undefined classes = "" panels: Panel[] = [] constructor(readonly view: EditorView, readonly top: boolean, readonly container: HTMLElement | undefined) { this.syncClasses() } sync(panels: Panel[]) { for (let p of this.panels) if (p.destroy && panels.indexOf(p) < 0) p.destroy() this.panels = panels this.syncDOM() } syncDOM() { if (this.panels.length == 0) { if (this.dom) { this.dom.remove() this.dom = undefined } return } if (!this.dom) { this.dom = document.createElement("div") this.dom.className = this.top ? "cm-panels cm-panels-top" : "cm-panels cm-panels-bottom" this.dom.style[this.top ? "top" : "bottom"] = "0" let parent = this.container || this.view.dom parent.insertBefore(this.dom, this.top ? parent.firstChild : null) } let curDOM = this.dom.firstChild for (let panel of this.panels) { if (panel.dom.parentNode == this.dom) { while (curDOM != panel.dom) curDOM = rm(curDOM!) curDOM = curDOM!.nextSibling } else { this.dom.insertBefore(panel.dom, curDOM) } } while (curDOM) curDOM = rm(curDOM) } scrollMargin() { return !this.dom || this.container ? 0 : Math.max(0, this.top ? this.dom.getBoundingClientRect().bottom - Math.max(0, this.view.scrollDOM.getBoundingClientRect().top) : Math.min(innerHeight, this.view.scrollDOM.getBoundingClientRect().bottom) - this.dom.getBoundingClientRect().top) } syncClasses() { if (!this.container || this.classes == this.view.themeClasses) return for (let cls of this.classes.split(" ")) if (cls) this.container.classList.remove(cls) for (let cls of (this.classes = this.view.themeClasses).split(" ")) if (cls) this.container.classList.add(cls) } } function rm(node: ChildNode) { let next = node.nextSibling node.remove() return next } /// A function that initializes a panel. Used in /// [`showPanel`](#view.showPanel). export type PanelConstructor = (view: EditorView) => Panel /// Opening a panel is done by providing a constructor function for /// the panel through this facet. (The panel is closed again when its /// constructor is no longer provided.) Values of `null` are ignored. export const showPanel = Facet.define({ enables: panelPlugin }) view-6.26.3/src/placeholder.ts000066400000000000000000000037001460616253600162140ustar00rootroot00000000000000import {Extension} from "@codemirror/state" import {ViewPlugin} from "./extension" import {Decoration, DecorationSet, WidgetType} from "./decoration" import {EditorView} from "./editorview" import {clientRectsFor, flattenRect} from "./dom" class Placeholder extends WidgetType { constructor(readonly content: string | HTMLElement) { super() } toDOM() { let wrap = document.createElement("span") wrap.className = "cm-placeholder" wrap.style.pointerEvents = "none" wrap.appendChild(typeof this.content == "string" ? document.createTextNode(this.content) : this.content) if (typeof this.content == "string") wrap.setAttribute("aria-label", "placeholder " + this.content) else wrap.setAttribute("aria-hidden", "true") return wrap } coordsAt(dom: HTMLElement) { let rects = dom.firstChild ? clientRectsFor(dom.firstChild) : [] if (!rects.length) return null let style = window.getComputedStyle(dom.parentNode as HTMLElement) let rect = flattenRect(rects[0], style.direction != "rtl") let lineHeight = parseInt(style.lineHeight) if (rect.bottom - rect.top > lineHeight * 1.5) return {left: rect.left, right: rect.right, top: rect.top, bottom: rect.top + lineHeight} return rect } ignoreEvent() { return false } } /// Extension that enables a placeholder—a piece of example content /// to show when the editor is empty. export function placeholder(content: string | HTMLElement): Extension { return ViewPlugin.fromClass(class { placeholder: DecorationSet constructor(readonly view: EditorView) { this.placeholder = content ? Decoration.set([Decoration.widget({widget: new Placeholder(content), side: 1}).range(0)]) : Decoration.none } update!: () => void // Kludge to convince TypeScript that this is a plugin value get decorations() { return this.view.state.doc.length ? Decoration.none : this.placeholder } }, {decorations: v => v.decorations}) } view-6.26.3/src/rectangular-selection.ts000066400000000000000000000117061460616253600202310ustar00rootroot00000000000000import {Extension, EditorSelection, EditorState, countColumn, findColumn} from "@codemirror/state" import {EditorView} from "./editorview" import {MouseSelectionStyle} from "./input" import {ViewPlugin} from "./extension" type Pos = {line: number, col: number, off: number} // Don't compute precise column positions for line offsets above this // (since it could get expensive). Assume offset==column for them. const MaxOff = 2000 function rectangleFor(state: EditorState, a: Pos, b: Pos) { let startLine = Math.min(a.line, b.line), endLine = Math.max(a.line, b.line) let ranges = [] if (a.off > MaxOff || b.off > MaxOff || a.col < 0 || b.col < 0) { let startOff = Math.min(a.off, b.off), endOff = Math.max(a.off, b.off) for (let i = startLine; i <= endLine; i++) { let line = state.doc.line(i) if (line.length <= endOff) ranges.push(EditorSelection.range(line.from + startOff, line.to + endOff)) } } else { let startCol = Math.min(a.col, b.col), endCol = Math.max(a.col, b.col) for (let i = startLine; i <= endLine; i++) { let line = state.doc.line(i) let start = findColumn(line.text, startCol, state.tabSize, true) if (start < 0) { ranges.push(EditorSelection.cursor(line.to)) } else { let end = findColumn(line.text, endCol, state.tabSize) ranges.push(EditorSelection.range(line.from + start, line.from + end)) } } } return ranges } function absoluteColumn(view: EditorView, x: number) { let ref = view.coordsAtPos(view.viewport.from) return ref ? Math.round(Math.abs((ref.left - x) / view.defaultCharacterWidth)) : -1 } function getPos(view: EditorView, event: MouseEvent) { let offset = view.posAtCoords({x: event.clientX, y: event.clientY}, false) let line = view.state.doc.lineAt(offset), off = offset - line.from let col = off > MaxOff ? -1 : off == line.length ? absoluteColumn(view, event.clientX) : countColumn(line.text, view.state.tabSize, offset - line.from) return {line: line.number, col, off} } function rectangleSelectionStyle(view: EditorView, event: MouseEvent) { let start = getPos(view, event)!, startSel = view.state.selection if (!start) return null return { update(update) { if (update.docChanged) { let newStart = update.changes.mapPos(update.startState.doc.line(start.line).from) let newLine = update.state.doc.lineAt(newStart) start = {line: newLine.number, col: start.col, off: Math.min(start.off, newLine.length)} startSel = startSel.map(update.changes) } }, get(event, _extend, multiple) { let cur = getPos(view, event) if (!cur) return startSel let ranges = rectangleFor(view.state, start, cur) if (!ranges.length) return startSel if (multiple) return EditorSelection.create(ranges.concat(startSel.ranges)) else return EditorSelection.create(ranges) } } as MouseSelectionStyle } /// Create an extension that enables rectangular selections. By /// default, it will react to left mouse drag with the Alt key held /// down. When such a selection occurs, the text within the rectangle /// that was dragged over will be selected, as one selection /// [range](#state.SelectionRange) per line. export function rectangularSelection(options?: { /// A custom predicate function, which takes a `mousedown` event and /// returns true if it should be used for rectangular selection. eventFilter?: (event: MouseEvent) => boolean }): Extension { let filter = options?.eventFilter || (e => e.altKey && e.button == 0) return EditorView.mouseSelectionStyle.of((view, event) => filter(event) ? rectangleSelectionStyle(view, event) : null) } const keys: {[key: string]: [number, (event: KeyboardEvent | MouseEvent) => boolean]} = { Alt: [18, e => !!e.altKey], Control: [17, e => !!e.ctrlKey], Shift: [16, e => !!e.shiftKey], Meta: [91, e => !!e.metaKey] } const showCrosshair = {style: "cursor: crosshair"} /// Returns an extension that turns the pointer cursor into a /// crosshair when a given modifier key, defaulting to Alt, is held /// down. Can serve as a visual hint that rectangular selection is /// going to happen when paired with /// [`rectangularSelection`](#view.rectangularSelection). export function crosshairCursor(options: { key?: "Alt" | "Control" | "Shift" | "Meta" } = {}): Extension { let [code, getter] = keys[options.key || "Alt"] let plugin = ViewPlugin.fromClass(class { isDown = false constructor(readonly view: EditorView) {} set(isDown: boolean) { if (this.isDown != isDown) { this.isDown = isDown this.view.update([]) } } }, { eventObservers: { keydown(e) { this.set(e.keyCode == code || getter(e)) }, keyup(e) { if (e.keyCode == code || !getter(e)) this.set(false) }, mousemove(e) { this.set(getter(e)) } } }) return [ plugin, EditorView.contentAttributes.of(view => view.plugin(plugin)?.isDown ? showCrosshair : null) ] } view-6.26.3/src/scrollpastend.ts000066400000000000000000000020041460616253600166030ustar00rootroot00000000000000import {Extension} from "@codemirror/state" import {ViewPlugin, ViewUpdate, contentAttributes} from "./extension" const plugin = ViewPlugin.fromClass(class { height = 1000 attrs = {style: "padding-bottom: 1000px"} update(update: ViewUpdate) { let {view} = update let height = view.viewState.editorHeight - view.defaultLineHeight - view.documentPadding.top - 0.5 if (height >= 0 && height != this.height) { this.height = height this.attrs = {style: `padding-bottom: ${height}px`} } } }) /// Returns an extension that makes sure the content has a bottom /// margin equivalent to the height of the editor, minus one line /// height, so that every line in the document can be scrolled to the /// top of the editor. /// /// This is only meaningful when the editor is scrollable, and should /// not be enabled in editors that take the size of their content. export function scrollPastEnd(): Extension { return [plugin, contentAttributes.of(view => view.plugin(plugin)?.attrs || null)] } view-6.26.3/src/special-chars.ts000066400000000000000000000146431460616253600164600ustar00rootroot00000000000000import {Decoration, DecorationSet, WidgetType} from "./decoration" import {ViewPlugin, ViewUpdate} from "./extension" import {EditorView} from "./editorview" import {MatchDecorator} from "./matchdecorator" import {combineConfig, Facet, Extension, countColumn, codePointAt} from "@codemirror/state" interface SpecialCharConfig { /// An optional function that renders the placeholder elements. /// /// The `description` argument will be text that clarifies what the /// character is, which should be provided to screen readers (for /// example with the /// [`aria-label`](https://www.w3.org/TR/wai-aria/#aria-label) /// attribute) and optionally shown to the user in other ways (such /// as the /// [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title) /// attribute). /// /// The given placeholder string is a suggestion for how to display /// the character visually. render?: ((code: number, description: string | null, placeholder: string) => HTMLElement) | null /// Regular expression that matches the special characters to /// highlight. Must have its 'g'/global flag set. specialChars?: RegExp /// Regular expression that can be used to add characters to the /// default set of characters to highlight. addSpecialChars?: RegExp | null } const UnicodeRegexpSupport = /x/.unicode != null ? "gu" : "g" const Specials = new RegExp("[\u0000-\u0008\u000a-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u2028\u2029\u202d\u202e\u2066\u2067\u2069\ufeff\ufff9-\ufffc]", UnicodeRegexpSupport) const Names: {[key: number]: string} = { 0: "null", 7: "bell", 8: "backspace", 10: "newline", 11: "vertical tab", 13: "carriage return", 27: "escape", 8203: "zero width space", 8204: "zero width non-joiner", 8205: "zero width joiner", 8206: "left-to-right mark", 8207: "right-to-left mark", 8232: "line separator", 8237: "left-to-right override", 8238: "right-to-left override", 8294: "left-to-right isolate", 8295: "right-to-left isolate", 8297: "pop directional isolate", 8233: "paragraph separator", 65279: "zero width no-break space", 65532: "object replacement" } let _supportsTabSize: null | boolean = null function supportsTabSize() { if (_supportsTabSize == null && typeof document != "undefined" && document.body) { let styles = document.body.style as any _supportsTabSize = (styles.tabSize ?? styles.MozTabSize) != null } return _supportsTabSize || false } const specialCharConfig = Facet.define & {replaceTabs?: boolean}>({ combine(configs) { let config: Required & {replaceTabs?: boolean} = combineConfig(configs, { render: null, specialChars: Specials, addSpecialChars: null }) if (config.replaceTabs = !supportsTabSize()) config.specialChars = new RegExp("\t|" + config.specialChars.source, UnicodeRegexpSupport) if (config.addSpecialChars) config.specialChars = new RegExp(config.specialChars.source + "|" + config.addSpecialChars.source, UnicodeRegexpSupport) return config } }) /// Returns an extension that installs highlighting of special /// characters. export function highlightSpecialChars( /// Configuration options. config: SpecialCharConfig = {} ): Extension { return [specialCharConfig.of(config), specialCharPlugin()] } let _plugin: Extension | null = null function specialCharPlugin() { return _plugin || (_plugin = ViewPlugin.fromClass(class { decorations: DecorationSet = Decoration.none decorationCache: {[char: number]: Decoration} = Object.create(null) decorator: MatchDecorator constructor(public view: EditorView) { this.decorator = this.makeDecorator(view.state.facet(specialCharConfig)) this.decorations = this.decorator.createDeco(view) } makeDecorator(conf: Required & {replaceTabs?: boolean}) { return new MatchDecorator({ regexp: conf.specialChars, decoration: (m, view, pos) => { let {doc} = view.state let code = codePointAt(m[0], 0) if (code == 9) { let line = doc.lineAt(pos) let size = view.state.tabSize, col = countColumn(line.text, size, pos - line.from) return Decoration.replace({ widget: new TabWidget((size - (col % size)) * this.view.defaultCharacterWidth / this.view.scaleX) }) } return this.decorationCache[code] || (this.decorationCache[code] = Decoration.replace({widget: new SpecialCharWidget(conf, code)})) }, boundary: conf.replaceTabs ? undefined : /[^]/ }) } update(update: ViewUpdate) { let conf = update.state.facet(specialCharConfig) if (update.startState.facet(specialCharConfig) != conf) { this.decorator = this.makeDecorator(conf) this.decorations = this.decorator.createDeco(update.view) } else { this.decorations = this.decorator.updateDeco(update, this.decorations) } } }, { decorations: v => v.decorations })) } const DefaultPlaceholder = "\u2022" // Assigns placeholder characters from the Control Pictures block to // ASCII control characters function placeholder(code: number): string { if (code >= 32) return DefaultPlaceholder if (code == 10) return "\u2424" return String.fromCharCode(9216 + code) } class SpecialCharWidget extends WidgetType { constructor(readonly options: Required, readonly code: number) { super() } eq(other: SpecialCharWidget) { return other.code == this.code } toDOM(view: EditorView) { let ph = placeholder(this.code) let desc = view.state.phrase("Control character") + " " + (Names[this.code] || "0x" + this.code.toString(16)) let custom = this.options.render && this.options.render(this.code, desc, ph) if (custom) return custom let span = document.createElement("span") span.textContent = ph span.title = desc span.setAttribute("aria-label", desc) span.className = "cm-specialChar" return span } ignoreEvent(): boolean { return false } } class TabWidget extends WidgetType { constructor(readonly width: number) { super() } eq(other: TabWidget) { return other.width == this.width } toDOM() { let span = document.createElement("span") span.textContent = "\t" span.className = "cm-tab" span.style.width = this.width + "px" return span } ignoreEvent(): boolean { return false } } view-6.26.3/src/theme.ts000066400000000000000000000165161460616253600150450ustar00rootroot00000000000000import {Facet} from "@codemirror/state" import {StyleModule, StyleSpec} from "style-mod" export const theme = Facet.define({combine: strs => strs.join(" ")}) export const darkTheme = Facet.define({combine: values => values.indexOf(true) > -1}) export const baseThemeID = StyleModule.newName(), baseLightID = StyleModule.newName(), baseDarkID = StyleModule.newName() export const lightDarkIDs = {"&light": "." + baseLightID, "&dark": "." + baseDarkID} export function buildTheme(main: string, spec: {[name: string]: StyleSpec}, scopes?: {[name: string]: string}) { return new StyleModule(spec, { finish(sel) { return /&/.test(sel) ? sel.replace(/&\w*/, m => { if (m == "&") return main if (!scopes || !scopes[m]) throw new RangeError(`Unsupported selector: ${m}`) return scopes[m] }) : main + " " + sel } }) } export const baseTheme = buildTheme("." + baseThemeID, { "&": { position: "relative !important", boxSizing: "border-box", "&.cm-focused": { // Provide a simple default outline to make sure a focused // editor is visually distinct. Can't leave the default behavior // because that will apply to the content element, which is // inside the scrollable container and doesn't include the // gutters. We also can't use an 'auto' outline, since those // are, for some reason, drawn behind the element content, which // will cause things like the active line background to cover // the outline (#297). outline: "1px dotted #212121" }, display: "flex !important", flexDirection: "column" }, ".cm-scroller": { display: "flex !important", alignItems: "flex-start !important", fontFamily: "monospace", lineHeight: 1.4, height: "100%", overflowX: "auto", position: "relative", zIndex: 0 }, ".cm-content": { margin: 0, flexGrow: 2, flexShrink: 0, display: "block", whiteSpace: "pre", wordWrap: "normal", // https://github.com/codemirror/dev/issues/456 boxSizing: "border-box", minHeight: "100%", padding: "4px 0", outline: "none", "&[contenteditable=true]": { WebkitUserModify: "read-write-plaintext-only", } }, ".cm-lineWrapping": { whiteSpace_fallback: "pre-wrap", // For IE whiteSpace: "break-spaces", wordBreak: "break-word", // For Safari, which doesn't support overflow-wrap: anywhere overflowWrap: "anywhere", flexShrink: 1 }, "&light .cm-content": { caretColor: "black" }, "&dark .cm-content": { caretColor: "white" }, ".cm-line": { display: "block", padding: "0 2px 0 6px" }, ".cm-layer": { position: "absolute", left: 0, top: 0, contain: "size style", "& > *": { position: "absolute" } }, "&light .cm-selectionBackground": { background: "#d9d9d9" }, "&dark .cm-selectionBackground": { background: "#222" }, "&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { background: "#d7d4f0" }, "&dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { background: "#233" }, ".cm-cursorLayer": { pointerEvents: "none" }, "&.cm-focused > .cm-scroller > .cm-cursorLayer": { animation: "steps(1) cm-blink 1.2s infinite" }, // Two animations defined so that we can switch between them to // restart the animation without forcing another style // recomputation. "@keyframes cm-blink": {"0%": {}, "50%": {opacity: 0}, "100%": {}}, "@keyframes cm-blink2": {"0%": {}, "50%": {opacity: 0}, "100%": {}}, ".cm-cursor, .cm-dropCursor": { borderLeft: "1.2px solid black", marginLeft: "-0.6px", pointerEvents: "none", }, ".cm-cursor": { display: "none" }, "&dark .cm-cursor": { borderLeftColor: "#444" }, ".cm-dropCursor": { position: "absolute" }, "&.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor": { display: "block" }, ".cm-iso": { unicodeBidi: "isolate" }, ".cm-announced": { position: "fixed", top: "-10000px" }, "@media print": { ".cm-announced": { display: "none" } }, "&light .cm-activeLine": { backgroundColor: "#cceeff44" }, "&dark .cm-activeLine": { backgroundColor: "#99eeff33" }, "&light .cm-specialChar": { color: "red" }, "&dark .cm-specialChar": { color: "#f78" }, ".cm-gutters": { flexShrink: 0, display: "flex", height: "100%", boxSizing: "border-box", insetInlineStart: 0, zIndex: 200 }, "&light .cm-gutters": { backgroundColor: "#f5f5f5", color: "#6c6c6c", borderRight: "1px solid #ddd" }, "&dark .cm-gutters": { backgroundColor: "#333338", color: "#ccc" }, ".cm-gutter": { display: "flex !important", // Necessary -- prevents margin collapsing flexDirection: "column", flexShrink: 0, boxSizing: "border-box", minHeight: "100%", overflow: "hidden" }, ".cm-gutterElement": { boxSizing: "border-box" }, ".cm-lineNumbers .cm-gutterElement": { padding: "0 3px 0 5px", minWidth: "20px", textAlign: "right", whiteSpace: "nowrap" }, "&light .cm-activeLineGutter": { backgroundColor: "#e2f2ff" }, "&dark .cm-activeLineGutter": { backgroundColor: "#222227" }, ".cm-panels": { boxSizing: "border-box", position: "sticky", left: 0, right: 0 }, "&light .cm-panels": { backgroundColor: "#f5f5f5", color: "black" }, "&light .cm-panels-top": { borderBottom: "1px solid #ddd" }, "&light .cm-panels-bottom": { borderTop: "1px solid #ddd" }, "&dark .cm-panels": { backgroundColor: "#333338", color: "white" }, ".cm-tab": { display: "inline-block", overflow: "hidden", verticalAlign: "bottom" }, ".cm-widgetBuffer": { verticalAlign: "text-top", height: "1em", width: 0, display: "inline" }, ".cm-placeholder": { color: "#888", display: "inline-block", verticalAlign: "top", }, ".cm-highlightSpace:before": { content: "attr(data-display)", position: "absolute", pointerEvents: "none", color: "#888" }, ".cm-highlightTab": { backgroundImage: `url('data:image/svg+xml,')`, backgroundSize: "auto 100%", backgroundPosition: "right 90%", backgroundRepeat: "no-repeat" }, ".cm-trailingSpace": { backgroundColor: "#ff332255" }, ".cm-button": { verticalAlign: "middle", color: "inherit", fontSize: "70%", padding: ".2em 1em", borderRadius: "1px" }, "&light .cm-button": { backgroundImage: "linear-gradient(#eff1f5, #d9d9df)", border: "1px solid #888", "&:active": { backgroundImage: "linear-gradient(#b4b4b4, #d0d3d6)" } }, "&dark .cm-button": { backgroundImage: "linear-gradient(#393939, #111)", border: "1px solid #888", "&:active": { backgroundImage: "linear-gradient(#111, #333)" } }, ".cm-textfield": { verticalAlign: "middle", color: "inherit", fontSize: "70%", border: "1px solid silver", padding: ".2em .5em" }, "&light .cm-textfield": { backgroundColor: "white" }, "&dark .cm-textfield": { border: "1px solid #555", backgroundColor: "inherit" } }, lightDarkIDs) view-6.26.3/src/tooltip.ts000066400000000000000000000743641460616253600154420ustar00rootroot00000000000000import {EditorState, Transaction, StateEffect, StateEffectType, Facet, StateField, Extension, MapMode, FacetReader} from "@codemirror/state" import {EditorView} from "./editorview" import {ViewPlugin, ViewUpdate, logException} from "./extension" import {Direction} from "./bidi" import {WidgetView} from "./inlineview" import {Rect} from "./dom" import browser from "./browser" type Measured = { editor: DOMRect, parent: DOMRect, pos: (Rect | null)[], size: DOMRect[], space: Rect, scaleX: number, scaleY: number, makeAbsolute: boolean } const Outside = "-10000px" const enum Arrow { Size = 7, Offset = 14 } class TooltipViewManager { private input: readonly (Tooltip | null)[] tooltips: readonly Tooltip[] tooltipViews: readonly TooltipView[] constructor( view: EditorView, private readonly facet: FacetReader, private readonly createTooltipView: (tooltip: Tooltip, after: TooltipView | null) => TooltipView, private readonly removeTooltipView: (tooltipView: TooltipView) => void ) { this.input = view.state.facet(facet) this.tooltips = this.input.filter(t => t) as Tooltip[] let prev: TooltipView | null = null this.tooltipViews = this.tooltips.map(t => prev = createTooltipView(t, prev)) } update(update: ViewUpdate, above?: boolean[]) { let input = update.state.facet(this.facet) let tooltips = input.filter(x => x) as Tooltip[] if (input === this.input) { for (let t of this.tooltipViews) if (t.update) t.update(update) return false } let tooltipViews: TooltipView[] = [], newAbove: boolean[] | null = above ? [] : null for (let i = 0; i < tooltips.length; i++) { let tip = tooltips[i], known = -1 if (!tip) continue for (let i = 0; i < this.tooltips.length; i++) { let other = this.tooltips[i] if (other && other.create == tip.create) known = i } if (known < 0) { tooltipViews[i] = this.createTooltipView(tip, i ? tooltipViews[i - 1] : null) if (newAbove) newAbove[i] = !!tip.above } else { let tooltipView = tooltipViews[i] = this.tooltipViews[known] if (newAbove) newAbove[i] = above![known] if (tooltipView.update) tooltipView.update(update) } } for (let t of this.tooltipViews) if (tooltipViews.indexOf(t) < 0) { this.removeTooltipView(t) t.destroy?.() } if (above) { newAbove!.forEach((val, i) => above[i] = val) above.length = newAbove!.length } this.input = input this.tooltips = tooltips this.tooltipViews = tooltipViews return true } } /// Creates an extension that configures tooltip behavior. export function tooltips(config: { /// By default, tooltips use `"fixed"` /// [positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/position), /// which has the advantage that tooltips don't get cut off by /// scrollable parent elements. However, CSS rules like `contain: /// layout` can break fixed positioning in child nodes, which can be /// worked about by using `"absolute"` here. /// /// On iOS, which at the time of writing still doesn't properly /// support fixed positioning, the library always uses absolute /// positioning. /// /// If the tooltip parent element sits in a transformed element, the /// library also falls back to absolute positioning. position?: "fixed" | "absolute", /// The element to put the tooltips into. By default, they are put /// in the editor (`cm-editor`) element, and that is usually what /// you want. But in some layouts that can lead to positioning /// issues, and you need to use a different parent to work around /// those. parent?: HTMLElement /// By default, when figuring out whether there is room for a /// tooltip at a given position, the extension considers the entire /// space between 0,0 and `innerWidth`,`innerHeight` to be available /// for showing tooltips. You can provide a function here that /// returns an alternative rectangle. tooltipSpace?: (view: EditorView) => Rect } = {}): Extension { return tooltipConfig.of(config) } type TooltipConfig = { position: "fixed" | "absolute", parent: HTMLElement | null, tooltipSpace: (view: EditorView) => Rect } function windowSpace(view: EditorView) { let {win} = view return {top: 0, left: 0, bottom: win.innerHeight, right: win.innerWidth} } const tooltipConfig = Facet.define, TooltipConfig>({ combine: values => ({ position: browser.ios ? "absolute" : values.find(conf => conf.position)?.position || "fixed", parent: values.find(conf => conf.parent)?.parent || null, tooltipSpace: values.find(conf => conf.tooltipSpace)?.tooltipSpace || windowSpace, }) }) const knownHeight = new WeakMap() const tooltipPlugin = ViewPlugin.fromClass(class { manager: TooltipViewManager above: boolean[] = [] measureReq: {read: () => Measured, write: (m: Measured) => void, key: any} inView = true position: "fixed" | "absolute" madeAbsolute = false parent: HTMLElement | null container!: HTMLElement classes: string intersectionObserver: IntersectionObserver | null resizeObserver: ResizeObserver | null lastTransaction = 0 measureTimeout = -1 constructor(readonly view: EditorView) { let config = view.state.facet(tooltipConfig) this.position = config.position this.parent = config.parent this.classes = view.themeClasses this.createContainer() this.measureReq = {read: this.readMeasure.bind(this), write: this.writeMeasure.bind(this), key: this} this.resizeObserver = typeof ResizeObserver == "function" ? new ResizeObserver(() => this.measureSoon()) : null this.manager = new TooltipViewManager(view, showTooltip, (t, p) => this.createTooltip(t, p), t => { if (this.resizeObserver) this.resizeObserver.unobserve(t.dom) t.dom.remove() }) this.above = this.manager.tooltips.map(t => !!t.above) this.intersectionObserver = typeof IntersectionObserver == "function" ? new IntersectionObserver(entries => { if (Date.now() > this.lastTransaction - 50 && entries.length > 0 && entries[entries.length - 1].intersectionRatio < 1) this.measureSoon() }, {threshold: [1]}) : null this.observeIntersection() view.win.addEventListener("resize", this.measureSoon = this.measureSoon.bind(this)) this.maybeMeasure() } createContainer() { if (this.parent) { this.container = document.createElement("div") this.container.style.position = "relative" this.container.className = this.view.themeClasses this.parent.appendChild(this.container) } else { this.container = this.view.dom } } observeIntersection() { if (this.intersectionObserver) { this.intersectionObserver.disconnect() for (let tooltip of this.manager.tooltipViews) this.intersectionObserver.observe(tooltip.dom) } } measureSoon() { if (this.measureTimeout < 0) this.measureTimeout = setTimeout(() => { this.measureTimeout = -1 this.maybeMeasure() }, 50) } update(update: ViewUpdate) { if (update.transactions.length) this.lastTransaction = Date.now() let updated = this.manager.update(update, this.above) if (updated) this.observeIntersection() let shouldMeasure = updated || update.geometryChanged let newConfig = update.state.facet(tooltipConfig) if (newConfig.position != this.position && !this.madeAbsolute) { this.position = newConfig.position for (let t of this.manager.tooltipViews) t.dom.style.position = this.position shouldMeasure = true } if (newConfig.parent != this.parent) { if (this.parent) this.container.remove() this.parent = newConfig.parent this.createContainer() for (let t of this.manager.tooltipViews) this.container.appendChild(t.dom) shouldMeasure = true } else if (this.parent && this.view.themeClasses != this.classes) { this.classes = this.container.className = this.view.themeClasses } if (shouldMeasure) this.maybeMeasure() } createTooltip(tooltip: Tooltip, prev: TooltipView | null) { let tooltipView = tooltip.create(this.view) let before = prev ? prev.dom : null tooltipView.dom.classList.add("cm-tooltip") if (tooltip.arrow && !tooltipView.dom.querySelector(".cm-tooltip > .cm-tooltip-arrow")) { let arrow = document.createElement("div") arrow.className = "cm-tooltip-arrow" tooltipView.dom.appendChild(arrow) } tooltipView.dom.style.position = this.position tooltipView.dom.style.top = Outside tooltipView.dom.style.left = "0px" this.container.insertBefore(tooltipView.dom, before) if (tooltipView.mount) tooltipView.mount(this.view) if (this.resizeObserver) this.resizeObserver.observe(tooltipView.dom) return tooltipView } destroy() { this.view.win.removeEventListener("resize", this.measureSoon) for (let tooltipView of this.manager.tooltipViews) { tooltipView.dom.remove() tooltipView.destroy?.() } if (this.parent) this.container.remove() this.resizeObserver?.disconnect() this.intersectionObserver?.disconnect() clearTimeout(this.measureTimeout) } readMeasure(): Measured { let editor = this.view.dom.getBoundingClientRect() let scaleX = 1, scaleY = 1, makeAbsolute = false if (this.position == "fixed" && this.manager.tooltipViews.length) { let {dom} = this.manager.tooltipViews[0] if (browser.gecko) { // Firefox sets the element's `offsetParent` to the // transformed element when a transform interferes with fixed // positioning. makeAbsolute = dom.offsetParent != this.container.ownerDocument.body } else if (dom.style.top == Outside && dom.style.left == "0px") { // On other browsers, we have to awkwardly try and use other // information to detect a transform. let rect = dom.getBoundingClientRect() makeAbsolute = Math.abs(rect.top + 10000) > 1 || Math.abs(rect.left) > 1 } } if (makeAbsolute || this.position == "absolute") { if (this.parent) { let rect = this.parent.getBoundingClientRect() if (rect.width && rect.height) { scaleX = rect.width / this.parent.offsetWidth scaleY = rect.height / this.parent.offsetHeight } } else { ;({scaleX, scaleY} = this.view.viewState) } } return { editor, parent: this.parent ? this.container.getBoundingClientRect() : editor, pos: this.manager.tooltips.map((t, i) => { let tv = this.manager.tooltipViews[i] return tv.getCoords ? tv.getCoords(t.pos) : this.view.coordsAtPos(t.pos) }), size: this.manager.tooltipViews.map(({dom}) => dom.getBoundingClientRect()), space: this.view.state.facet(tooltipConfig).tooltipSpace(this.view), scaleX, scaleY, makeAbsolute } } writeMeasure(measured: Measured) { if (measured.makeAbsolute) { this.madeAbsolute = true this.position = "absolute" for (let t of this.manager.tooltipViews) t.dom.style.position = "absolute" } let {editor, space, scaleX, scaleY} = measured let others = [] for (let i = 0; i < this.manager.tooltips.length; i++) { let tooltip = this.manager.tooltips[i], tView = this.manager.tooltipViews[i], {dom} = tView let pos = measured.pos[i], size = measured.size[i] // Hide tooltips that are outside of the editor. if (!pos || pos.bottom <= Math.max(editor.top, space.top) || pos.top >= Math.min(editor.bottom, space.bottom) || pos.right < Math.max(editor.left, space.left) - .1 || pos.left > Math.min(editor.right, space.right) + .1) { dom.style.top = Outside continue } let arrow: HTMLElement | null = tooltip.arrow ? tView.dom.querySelector(".cm-tooltip-arrow") : null let arrowHeight = arrow ? Arrow.Size : 0 let width = size.right - size.left, height = knownHeight.get(tView) ?? size.bottom - size.top let offset = tView.offset || noOffset, ltr = this.view.textDirection == Direction.LTR let left = size.width > space.right - space.left ? (ltr ? space.left : space.right - size.width) : ltr ? Math.min(pos.left - (arrow ? Arrow.Offset : 0) + offset.x, space.right - width) : Math.max(space.left, pos.left - width + (arrow ? Arrow.Offset : 0) - offset.x) let above = this.above[i] if (!tooltip.strictSide && (above ? pos.top - (size.bottom - size.top) - offset.y < space.top : pos.bottom + (size.bottom - size.top) + offset.y > space.bottom) && above == (space.bottom - pos.bottom > pos.top - space.top)) above = this.above[i] = !above let spaceVert = (above ? pos.top - space.top : space.bottom - pos.bottom) - arrowHeight if (spaceVert < height && tView.resize !== false) { if (spaceVert < this.view.defaultLineHeight) { dom.style.top = Outside; continue } knownHeight.set(tView, height) dom.style.height = (height = spaceVert) / scaleY + "px" } else if (dom.style.height) { dom.style.height = "" } let top = above ? pos.top - height - arrowHeight - offset.y : pos.bottom + arrowHeight + offset.y let right = left + width if (tView.overlap !== true) for (let r of others) if (r.left < right && r.right > left && r.top < top + height && r.bottom > top) top = above ? r.top - height - 2 - arrowHeight : r.bottom + arrowHeight + 2 if (this.position == "absolute") { dom.style.top = (top - measured.parent.top) / scaleY + "px" dom.style.left = (left - measured.parent.left) / scaleX + "px" } else { dom.style.top = top / scaleY + "px" dom.style.left = left / scaleX + "px" } if (arrow) { let arrowLeft = pos.left + (ltr ? offset.x : -offset.x) - (left + Arrow.Offset - Arrow.Size) arrow.style.left = arrowLeft / scaleX + "px" } if (tView.overlap !== true) others.push({left, top, right, bottom: top + height}) dom.classList.toggle("cm-tooltip-above", above) dom.classList.toggle("cm-tooltip-below", !above) if (tView.positioned) tView.positioned(measured.space) } } maybeMeasure() { if (this.manager.tooltips.length) { if (this.view.inView) this.view.requestMeasure(this.measureReq) if (this.inView != this.view.inView) { this.inView = this.view.inView if (!this.inView) for (let tv of this.manager.tooltipViews) tv.dom.style.top = Outside } } } }, { eventObservers: { scroll() { this.maybeMeasure() } } }) const baseTheme = EditorView.baseTheme({ ".cm-tooltip": { zIndex: 100, boxSizing: "border-box" }, "&light .cm-tooltip": { border: "1px solid #bbb", backgroundColor: "#f5f5f5" }, "&light .cm-tooltip-section:not(:first-child)": { borderTop: "1px solid #bbb", }, "&dark .cm-tooltip": { backgroundColor: "#333338", color: "white" }, ".cm-tooltip-arrow": { height: `${Arrow.Size}px`, width: `${Arrow.Size * 2}px`, position: "absolute", zIndex: -1, overflow: "hidden", "&:before, &:after": { content: "''", position: "absolute", width: 0, height: 0, borderLeft: `${Arrow.Size}px solid transparent`, borderRight: `${Arrow.Size}px solid transparent`, }, ".cm-tooltip-above &": { bottom: `-${Arrow.Size}px`, "&:before": { borderTop: `${Arrow.Size}px solid #bbb`, }, "&:after": { borderTop: `${Arrow.Size}px solid #f5f5f5`, bottom: "1px" } }, ".cm-tooltip-below &": { top: `-${Arrow.Size}px`, "&:before": { borderBottom: `${Arrow.Size}px solid #bbb`, }, "&:after": { borderBottom: `${Arrow.Size}px solid #f5f5f5`, top: "1px" } }, }, "&dark .cm-tooltip .cm-tooltip-arrow": { "&:before": { borderTopColor: "#333338", borderBottomColor: "#333338" }, "&:after": { borderTopColor: "transparent", borderBottomColor: "transparent" } } }) /// Describes a tooltip. Values of this type, when provided through /// the [`showTooltip`](#view.showTooltip) facet, control the /// individual tooltips on the editor. export interface Tooltip { /// The document position at which to show the tooltip. pos: number /// The end of the range annotated by this tooltip, if different /// from `pos`. end?: number /// A constructor function that creates the tooltip's [DOM /// representation](#view.TooltipView). create(view: EditorView): TooltipView /// Whether the tooltip should be shown above or below the target /// position. Not guaranteed to be respected for hover tooltips /// since all hover tooltips for the same range are always /// positioned together. Defaults to false. above?: boolean /// Whether the `above` option should be honored when there isn't /// enough space on that side to show the tooltip inside the /// viewport. Defaults to false. strictSide?: boolean, /// When set to true, show a triangle connecting the tooltip element /// to position `pos`. arrow?: boolean } /// Describes the way a tooltip is displayed. export interface TooltipView { /// The DOM element to position over the editor. dom: HTMLElement /// Adjust the position of the tooltip relative to its anchor /// position. A positive `x` value will move the tooltip /// horizontally along with the text direction (so right in /// left-to-right context, left in right-to-left). A positive `y` /// will move the tooltip up when it is above its anchor, and down /// otherwise. offset?: {x: number, y: number} /// By default, a tooltip's screen position will be based on the /// text position of its `pos` property. This method can be provided /// to make the tooltip view itself responsible for finding its /// screen position. getCoords?: (pos: number) => Rect /// By default, tooltips are moved when they overlap with other /// tooltips. Set this to `true` to disable that behavior for this /// tooltip. overlap?: boolean /// Called after the tooltip is added to the DOM for the first time. mount?(view: EditorView): void /// Update the DOM element for a change in the view's state. update?(update: ViewUpdate): void /// Called when the tooltip is removed from the editor or the editor /// is destroyed. destroy?(): void /// Called when the tooltip has been (re)positioned. The argument is /// the [space](#view.tooltips^config.tooltipSpace) available to the /// tooltip. positioned?(space: Rect): void, /// By default, the library will restrict the size of tooltips so /// that they don't stick out of the available space. Set this to /// false to disable that. resize?: boolean } const noOffset = {x: 0, y: 0} /// Facet to which an extension can add a value to show a tooltip. export const showTooltip = Facet.define({ enables: [tooltipPlugin, baseTheme] }) const showHoverTooltip = Facet.define({ combine: inputs => inputs.reduce((a, i) => a.concat(i), []) }) class HoverTooltipHost implements TooltipView { private readonly manager: TooltipViewManager dom: HTMLElement mounted: boolean = false // Needs to be static so that host tooltip instances always match static create(view: EditorView) { return new HoverTooltipHost(view) } private constructor(readonly view: EditorView) { this.dom = document.createElement("div") this.dom.classList.add("cm-tooltip-hover") this.manager = new TooltipViewManager(view, showHoverTooltip, (t, p) => this.createHostedView(t, p), t => t.dom.remove()) } createHostedView(tooltip: Tooltip, prev: TooltipView | null) { let hostedView = tooltip.create(this.view) hostedView.dom.classList.add("cm-tooltip-section") this.dom.insertBefore(hostedView.dom, prev ? prev.dom.nextSibling : this.dom.firstChild) if (this.mounted && hostedView.mount) hostedView.mount(this.view) return hostedView } mount(view: EditorView) { for (let hostedView of this.manager.tooltipViews) { if (hostedView.mount) hostedView.mount(view) } this.mounted = true } positioned(space: Rect) { for (let hostedView of this.manager.tooltipViews) { if (hostedView.positioned) hostedView.positioned(space) } } update(update: ViewUpdate) { this.manager.update(update) } destroy() { for (let t of this.manager.tooltipViews) t.destroy?.() } passProp(name: Key): TooltipView[Key] | undefined { let value: TooltipView[Key] | undefined = undefined for (let view of this.manager.tooltipViews) { let given = view[name] if (given !== undefined) { if (value === undefined) value = given else if (value !== given) return undefined } } return value } get offset() { return this.passProp("offset") } get getCoords() { return this.passProp("getCoords") } get overlap() { return this.passProp("overlap") } get resize() { return this.passProp("resize") } } const showHoverTooltipHost = showTooltip.compute([showHoverTooltip], state => { let tooltips = state.facet(showHoverTooltip) if (tooltips.length === 0) return null return { pos: Math.min(...tooltips.map(t => t.pos)), end: Math.max(...tooltips.map(t => t.end ?? t.pos)), create: HoverTooltipHost.create, above: tooltips[0].above, arrow: tooltips.some(t => t.arrow), } }) const enum Hover { Time = 300, MaxDist = 6 } type HoverSource = (view: EditorView, pos: number, side: -1 | 1) => Tooltip | readonly Tooltip[] | null | Promise class HoverPlugin { lastMove: {x: number, y: number, target: HTMLElement, time: number} hoverTimeout = -1 restartTimeout = -1 pending: {pos: number} | null = null constructor(readonly view: EditorView, readonly source: HoverSource, readonly field: StateField, readonly setHover: StateEffectType, readonly hoverTime: number) { this.lastMove = {x: 0, y: 0, target: view.dom, time: 0} this.checkHover = this.checkHover.bind(this) view.dom.addEventListener("mouseleave", this.mouseleave = this.mouseleave.bind(this)) view.dom.addEventListener("mousemove", this.mousemove = this.mousemove.bind(this)) } update() { if (this.pending) { this.pending = null clearTimeout(this.restartTimeout) this.restartTimeout = setTimeout(() => this.startHover(), 20) } } get active() { return this.view.state.field(this.field) } checkHover() { this.hoverTimeout = -1 if (this.active.length) return let hovered = Date.now() - this.lastMove.time if (hovered < this.hoverTime) this.hoverTimeout = setTimeout(this.checkHover, this.hoverTime - hovered) else this.startHover() } startHover() { clearTimeout(this.restartTimeout) let {view, lastMove} = this let desc = view.docView.nearest(lastMove.target) if (!desc) return let pos: number, side: -1 | 1 = 1 if (desc instanceof WidgetView) { pos = desc.posAtStart } else { pos = view.posAtCoords(lastMove)! if (pos == null) return let posCoords = view.coordsAtPos(pos) if (!posCoords || lastMove.y < posCoords.top || lastMove.y > posCoords.bottom || lastMove.x < posCoords.left - view.defaultCharacterWidth || lastMove.x > posCoords.right + view.defaultCharacterWidth) return let bidi = view.bidiSpans(view.state.doc.lineAt(pos)).find(s => s.from <= pos! && s.to >= pos!) let rtl = bidi && bidi.dir == Direction.RTL ? -1 : 1 side = (lastMove.x < posCoords.left ? -rtl : rtl) as -1 | 1 } let open = this.source(view, pos, side) if ((open as any)?.then) { let pending = this.pending = {pos} ;(open as Promise).then(result => { if (this.pending == pending) { this.pending = null if (result && !(Array.isArray(result) && !result.length)) view.dispatch({effects: this.setHover.of(Array.isArray(result) ? result : [result])}) } }, e => logException(view.state, e, "hover tooltip")) } else if (open && !(Array.isArray(open) && !open.length)) { view.dispatch({effects: this.setHover.of(Array.isArray(open) ? open : [open])}) } } get tooltip() { let plugin = this.view.plugin(tooltipPlugin) let index = plugin ? plugin.manager.tooltips.findIndex(t => t.create == HoverTooltipHost.create) : -1 return index > -1 ? plugin!.manager.tooltipViews[index] : null } mousemove(event: MouseEvent) { this.lastMove = {x: event.clientX, y: event.clientY, target: event.target as HTMLElement, time: Date.now()} if (this.hoverTimeout < 0) this.hoverTimeout = setTimeout(this.checkHover, this.hoverTime) let {active, tooltip} = this if (active.length && tooltip && !isInTooltip(tooltip.dom, event) || this.pending) { let {pos} = active[0] || this.pending!, end = active[0]?.end ?? pos if ((pos == end ? this.view.posAtCoords(this.lastMove) != pos : !isOverRange(this.view, pos, end, event.clientX, event.clientY, Hover.MaxDist))) { this.view.dispatch({effects: this.setHover.of([])}) this.pending = null } } } mouseleave(event: MouseEvent) { clearTimeout(this.hoverTimeout) this.hoverTimeout = -1 let {active} = this if (active.length) { let {tooltip} = this let inTooltip = tooltip && tooltip.dom.contains(event.relatedTarget as HTMLElement) if (!inTooltip) this.view.dispatch({effects: this.setHover.of([])}) else this.watchTooltipLeave(tooltip!.dom) } } watchTooltipLeave(tooltip: HTMLElement) { let watch = (event: MouseEvent) => { tooltip.removeEventListener("mouseleave", watch) if (this.active.length && !this.view.dom.contains(event.relatedTarget as HTMLElement)) this.view.dispatch({effects: this.setHover.of([])}) } tooltip.addEventListener("mouseleave", watch) } destroy() { clearTimeout(this.hoverTimeout) this.view.dom.removeEventListener("mouseleave", this.mouseleave) this.view.dom.removeEventListener("mousemove", this.mousemove) } } const tooltipMargin = 4 function isInTooltip(tooltip: HTMLElement, event: MouseEvent) { let rect = tooltip.getBoundingClientRect() return event.clientX >= rect.left - tooltipMargin && event.clientX <= rect.right + tooltipMargin && event.clientY >= rect.top - tooltipMargin && event.clientY <= rect.bottom + tooltipMargin } function isOverRange(view: EditorView, from: number, to: number, x: number, y: number, margin: number) { let rect = view.scrollDOM.getBoundingClientRect() let docBottom = view.documentTop + view.documentPadding.top + view.contentHeight if (rect.left > x || rect.right < x || rect.top > y || Math.min(rect.bottom, docBottom) < y) return false let pos = view.posAtCoords({x, y}, false) return pos >= from && pos <= to } /// Set up a hover tooltip, which shows up when the pointer hovers /// over ranges of text. The callback is called when the mouse hovers /// over the document text. It should, if there is a tooltip /// associated with position `pos`, return the tooltip description /// (either directly or in a promise). The `side` argument indicates /// on which side of the position the pointer is—it will be -1 if the /// pointer is before the position, 1 if after the position. /// /// Note that all hover tooltips are hosted within a single tooltip /// container element. This allows multiple tooltips over the same /// range to be "merged" together without overlapping. export function hoverTooltip( source: HoverSource, options: { /// Controls whether a transaction hides the tooltip. The default /// is to not hide. hideOn?: (tr: Transaction, tooltip: Tooltip) => boolean, /// When enabled (this defaults to false), close the tooltip /// whenever the document changes or the selection is set. hideOnChange?: boolean | "touch", /// Hover time after which the tooltip should appear, in /// milliseconds. Defaults to 300ms. hoverTime?: number } = {} ): Extension { let setHover = StateEffect.define() let hoverState = StateField.define({ create() { return [] }, update(value, tr) { if (value.length) { if (options.hideOnChange && (tr.docChanged || tr.selection)) value = [] else if (options.hideOn) value = value.filter(v => !options.hideOn!(tr, v)) if (tr.docChanged) { let mapped = [] for (let tooltip of value) { let newPos = tr.changes.mapPos(tooltip.pos, -1, MapMode.TrackDel) if (newPos != null) { let copy: Tooltip = Object.assign(Object.create(null), tooltip) copy.pos = newPos if (copy.end != null) copy.end = tr.changes.mapPos(copy.end) mapped.push(copy) } } value = mapped } } for (let effect of tr.effects) { if (effect.is(setHover)) value = effect.value if (effect.is(closeHoverTooltipEffect)) value = [] } return value }, provide: f => showHoverTooltip.from(f) }) return [ hoverState, ViewPlugin.define(view => new HoverPlugin(view, source, hoverState, setHover, options.hoverTime || Hover.Time)), showHoverTooltipHost ] } /// Get the active tooltip view for a given tooltip, if available. export function getTooltip(view: EditorView, tooltip: Tooltip): TooltipView | null { let plugin = view.plugin(tooltipPlugin) if (!plugin) return null let found = plugin.manager.tooltips.indexOf(tooltip) return found < 0 ? null : plugin.manager.tooltipViews[found] } /// Returns true if any hover tooltips are currently active. export function hasHoverTooltips(state: EditorState) { return state.facet(showHoverTooltip).some(x => x) } const closeHoverTooltipEffect = StateEffect.define() /// Transaction effect that closes all hover tooltips. export const closeHoverTooltips = closeHoverTooltipEffect.of(null) /// Tell the tooltip extension to recompute the position of the active /// tooltips. This can be useful when something happens (such as a /// re-positioning or CSS change affecting the editor) that could /// invalidate the existing tooltip positions. export function repositionTooltips(view: EditorView) { let plugin = view.plugin(tooltipPlugin) if (plugin) plugin.maybeMeasure() } view-6.26.3/src/viewstate.ts000066400000000000000000000700521460616253600157510ustar00rootroot00000000000000import {Text, EditorState, ChangeSet, ChangeDesc, RangeSet, EditorSelection} from "@codemirror/state" import {Rect, isScrolledToBottom, getScale} from "./dom" import {HeightMap, HeightOracle, BlockInfo, MeasuredHeights, QueryType, heightRelevantDecoChanges} from "./heightmap" import {decorations, ViewUpdate, UpdateFlag, ChangedRange, ScrollTarget, nativeSelectionHidden, contentAttributes} from "./extension" import {WidgetType, Decoration, DecorationSet} from "./decoration" import {EditorView} from "./editorview" import {Direction} from "./bidi" function visiblePixelRange(dom: HTMLElement, paddingTop: number): Rect { let rect = dom.getBoundingClientRect() let doc = dom.ownerDocument, win = doc.defaultView || window let left = Math.max(0, rect.left), right = Math.min(win.innerWidth, rect.right) let top = Math.max(0, rect.top), bottom = Math.min(win.innerHeight, rect.bottom) for (let parent = dom.parentNode as Node | null; parent && parent != doc.body;) { if (parent.nodeType == 1) { let elt = parent as HTMLElement let style = window.getComputedStyle(elt) if ((elt.scrollHeight > elt.clientHeight || elt.scrollWidth > elt.clientWidth) && style.overflow != "visible") { let parentRect = elt.getBoundingClientRect() left = Math.max(left, parentRect.left) right = Math.min(right, parentRect.right) top = Math.max(top, parentRect.top) bottom = parent == dom.parentNode ? parentRect.bottom : Math.min(bottom, parentRect.bottom) } parent = style.position == "absolute" || style.position == "fixed" ? elt.offsetParent : elt.parentNode } else if (parent.nodeType == 11) { // Shadow root parent = (parent as ShadowRoot).host } else { break } } return {left: left - rect.left, right: Math.max(left, right) - rect.left, top: top - (rect.top + paddingTop), bottom: Math.max(top, bottom) - (rect.top + paddingTop)} } function fullPixelRange(dom: HTMLElement, paddingTop: number): Rect { let rect = dom.getBoundingClientRect() return {left: 0, right: rect.right - rect.left, top: paddingTop, bottom: rect.bottom - (rect.top + paddingTop)} } const enum VP { // FIXME look into appropriate value of this through benchmarking etc Margin = 1000, // coveredBy requires at least this many extra pixels to be covered MinCoverMargin = 10, MaxCoverMargin = VP.Margin / 4, // Beyond this size, DOM layout starts to break down in browsers // because they use fixed-precision numbers to store dimensions. MaxDOMHeight = 7e6 } // Line gaps are placeholder widgets used to hide pieces of overlong // lines within the viewport, as a kludge to keep the editor // responsive when a ridiculously long line is loaded into it. export class LineGap { constructor(readonly from: number, readonly to: number, readonly size: number) {} static same(a: readonly LineGap[], b: readonly LineGap[]) { if (a.length != b.length) return false for (let i = 0; i < a.length; i++) { let gA = a[i], gB = b[i] if (gA.from != gB.from || gA.to != gB.to || gA.size != gB.size) return false } return true } draw(viewState: ViewState, wrapping: boolean) { return Decoration.replace({ widget: new LineGapWidget(this.size * (wrapping ? viewState.scaleY : viewState.scaleX), wrapping) }).range(this.from, this.to) } } class LineGapWidget extends WidgetType { constructor(readonly size: number, readonly vertical: boolean) { super() } eq(other: LineGapWidget) { return other.size == this.size && other.vertical == this.vertical } toDOM() { let elt = document.createElement("div") if (this.vertical) { elt.style.height = this.size + "px" } else { elt.style.width = this.size + "px" elt.style.height = "2px" elt.style.display = "inline-block" } return elt } get estimatedHeight() { return this.vertical ? this.size : -1 } } const enum LG { Margin = 2000, MarginWrap = 10000, SelectionMargin = 10, } export class ViewState { // These are contentDOM-local coordinates pixelViewport: Rect = {left: 0, right: window.innerWidth, top: 0, bottom: 0} inView = true paddingTop = 0 // Padding above the document, scaled paddingBottom = 0 // Padding below the document, scaled contentDOMWidth = 0 // contentDOM.getBoundingClientRect().width contentDOMHeight = 0 // contentDOM.getBoundingClientRect().height editorHeight = 0 // scrollDOM.clientHeight, unscaled editorWidth = 0 // scrollDOM.clientWidth, unscaled scrollTop = 0 // Last seen scrollDOM.scrollTop, scaled scrolledToBottom = false // The CSS-transformation scale of the editor (transformed size / // concrete size) scaleX = 1 scaleY = 1 // The vertical position (document-relative) to which to anchor the // scroll position. -1 means anchor to the end of the document. scrollAnchorPos = 0 // The height at the anchor position. Set by the DOM update phase. // -1 means no height available. scrollAnchorHeight = -1 heightOracle: HeightOracle heightMap: HeightMap // See VP.MaxDOMHeight scaler = IdScaler scrollTarget: ScrollTarget | null = null // Briefly set to true when printing, to disable viewport limiting printing = false // Flag set when editor content was redrawn, so that the next // measure stage knows it must read DOM layout mustMeasureContent = true stateDeco: readonly DecorationSet[] viewportLines!: BlockInfo[] defaultTextDirection: Direction = Direction.LTR // The main viewport for the visible part of the document viewport: Viewport // If the main selection starts or ends outside of the main // viewport, extra single-line viewports are created for these // points, so that the DOM selection doesn't fall in a gap. viewports!: readonly Viewport[] visibleRanges: readonly {from: number, to: number}[] = [] lineGaps: readonly LineGap[] lineGapDeco: DecorationSet // Cursor 'assoc' is only significant when the cursor is on a line // wrap point, where it must stick to the character that it is // associated with. Since browsers don't provide a reasonable // interface to set or query this, when a selection is set that // might cause this to be significant, this flag is set. The next // measure phase will check whether the cursor is on a line-wrapping // boundary and, if so, reset it to make sure it is positioned in // the right place. mustEnforceCursorAssoc = false constructor(public state: EditorState) { let guessWrapping = state.facet(contentAttributes).some(v => typeof v != "function" && v.class == "cm-lineWrapping") this.heightOracle = new HeightOracle(guessWrapping) this.stateDeco = state.facet(decorations).filter(d => typeof d != "function") as readonly DecorationSet[] this.heightMap = HeightMap.empty().applyChanges(this.stateDeco, Text.empty, this.heightOracle.setDoc(state.doc), [new ChangedRange(0, 0, 0, state.doc.length)]) this.viewport = this.getViewport(0, null) this.updateViewportLines() this.updateForViewport() this.lineGaps = this.ensureLineGaps([]) this.lineGapDeco = Decoration.set(this.lineGaps.map(gap => gap.draw(this, false))) this.computeVisibleRanges() } updateForViewport() { let viewports = [this.viewport], {main} = this.state.selection for (let i = 0; i <= 1; i++) { let pos = i ? main.head : main.anchor if (!viewports.some(({from, to}) => pos >= from && pos <= to)) { let {from, to} = this.lineBlockAt(pos) viewports.push(new Viewport(from, to)) } } this.viewports = viewports.sort((a, b) => a.from - b.from) this.scaler = this.heightMap.height <= VP.MaxDOMHeight ? IdScaler : new BigScaler(this.heightOracle, this.heightMap, this.viewports) } updateViewportLines() { this.viewportLines = [] this.heightMap.forEachLine(this.viewport.from, this.viewport.to, this.heightOracle.setDoc(this.state.doc), 0, 0, block => { this.viewportLines.push(this.scaler.scale == 1 ? block : scaleBlock(block, this.scaler)) }) } update(update: ViewUpdate, scrollTarget: ScrollTarget | null = null) { this.state = update.state let prevDeco = this.stateDeco this.stateDeco = this.state.facet(decorations).filter(d => typeof d != "function") as readonly DecorationSet[] let contentChanges = update.changedRanges let heightChanges = ChangedRange.extendWithRanges(contentChanges, heightRelevantDecoChanges( prevDeco, this.stateDeco, update ? update.changes : ChangeSet.empty(this.state.doc.length))) let prevHeight = this.heightMap.height let scrollAnchor = this.scrolledToBottom ? null : this.scrollAnchorAt(this.scrollTop) this.heightMap = this.heightMap.applyChanges(this.stateDeco, update.startState.doc, this.heightOracle.setDoc(this.state.doc), heightChanges) if (this.heightMap.height != prevHeight) update.flags |= UpdateFlag.Height if (scrollAnchor) { this.scrollAnchorPos = update.changes.mapPos(scrollAnchor.from, -1) this.scrollAnchorHeight = scrollAnchor.top } else { this.scrollAnchorPos = -1 this.scrollAnchorHeight = this.heightMap.height } let viewport = heightChanges.length ? this.mapViewport(this.viewport, update.changes) : this.viewport if (scrollTarget && (scrollTarget.range.head < viewport.from || scrollTarget.range.head > viewport.to) || !this.viewportIsAppropriate(viewport)) viewport = this.getViewport(0, scrollTarget) let updateLines = !update.changes.empty || (update.flags & UpdateFlag.Height) || viewport.from != this.viewport.from || viewport.to != this.viewport.to this.viewport = viewport this.updateForViewport() if (updateLines) this.updateViewportLines() if (this.lineGaps.length || this.viewport.to - this.viewport.from > (LG.Margin << 1)) this.updateLineGaps(this.ensureLineGaps(this.mapLineGaps(this.lineGaps, update.changes))) update.flags |= this.computeVisibleRanges() if (scrollTarget) this.scrollTarget = scrollTarget if (!this.mustEnforceCursorAssoc && update.selectionSet && update.view.lineWrapping && update.state.selection.main.empty && update.state.selection.main.assoc && !update.state.facet(nativeSelectionHidden)) this.mustEnforceCursorAssoc = true } measure(view: EditorView) { let dom = view.contentDOM, style = window.getComputedStyle(dom) let oracle = this.heightOracle let whiteSpace = style.whiteSpace! this.defaultTextDirection = style.direction == "rtl" ? Direction.RTL : Direction.LTR let refresh = this.heightOracle.mustRefreshForWrapping(whiteSpace) let domRect = dom.getBoundingClientRect() let measureContent = refresh || this.mustMeasureContent || this.contentDOMHeight != domRect.height this.contentDOMHeight = domRect.height this.mustMeasureContent = false let result = 0, bias = 0 if (domRect.width && domRect.height) { let {scaleX, scaleY} = getScale(dom, domRect) if (scaleX > .005 && Math.abs(this.scaleX - scaleX) > .005 || scaleY > .005 && Math.abs(this.scaleY - scaleY) > .005) { this.scaleX = scaleX; this.scaleY = scaleY result |= UpdateFlag.Geometry refresh = measureContent = true } } // Vertical padding let paddingTop = (parseInt(style.paddingTop!) || 0) * this.scaleY let paddingBottom = (parseInt(style.paddingBottom!) || 0) * this.scaleY if (this.paddingTop != paddingTop || this.paddingBottom != paddingBottom) { this.paddingTop = paddingTop this.paddingBottom = paddingBottom result |= UpdateFlag.Geometry | UpdateFlag.Height } if (this.editorWidth != view.scrollDOM.clientWidth) { if (oracle.lineWrapping) measureContent = true this.editorWidth = view.scrollDOM.clientWidth result |= UpdateFlag.Geometry } let scrollTop = view.scrollDOM.scrollTop * this.scaleY if (this.scrollTop != scrollTop) { this.scrollAnchorHeight = -1 this.scrollTop = scrollTop } this.scrolledToBottom = isScrolledToBottom(view.scrollDOM) // Pixel viewport let pixelViewport = (this.printing ? fullPixelRange : visiblePixelRange)(dom, this.paddingTop) let dTop = pixelViewport.top - this.pixelViewport.top, dBottom = pixelViewport.bottom - this.pixelViewport.bottom this.pixelViewport = pixelViewport let inView = this.pixelViewport.bottom > this.pixelViewport.top && this.pixelViewport.right > this.pixelViewport.left if (inView != this.inView) { this.inView = inView if (inView) measureContent = true } if (!this.inView && !this.scrollTarget) return 0 let contentWidth = domRect.width if (this.contentDOMWidth != contentWidth || this.editorHeight != view.scrollDOM.clientHeight) { this.contentDOMWidth = domRect.width this.editorHeight = view.scrollDOM.clientHeight result |= UpdateFlag.Geometry } if (measureContent) { let lineHeights = view.docView.measureVisibleLineHeights(this.viewport) if (oracle.mustRefreshForHeights(lineHeights)) refresh = true if (refresh || oracle.lineWrapping && Math.abs(contentWidth - this.contentDOMWidth) > oracle.charWidth) { let {lineHeight, charWidth, textHeight} = view.docView.measureTextSize() refresh = lineHeight > 0 && oracle.refresh(whiteSpace, lineHeight, charWidth, textHeight, contentWidth / charWidth, lineHeights) if (refresh) { view.docView.minWidth = 0 result |= UpdateFlag.Geometry } } if (dTop > 0 && dBottom > 0) bias = Math.max(dTop, dBottom) else if (dTop < 0 && dBottom < 0) bias = Math.min(dTop, dBottom) oracle.heightChanged = false for (let vp of this.viewports) { let heights = vp.from == this.viewport.from ? lineHeights : view.docView.measureVisibleLineHeights(vp) this.heightMap = ( refresh ? HeightMap.empty().applyChanges(this.stateDeco, Text.empty, this.heightOracle, [new ChangedRange(0, 0, 0, view.state.doc.length)]) : this.heightMap ).updateHeight(oracle, 0, refresh, new MeasuredHeights(vp.from, heights)) } if (oracle.heightChanged) result |= UpdateFlag.Height } let viewportChange = !this.viewportIsAppropriate(this.viewport, bias) || this.scrollTarget && (this.scrollTarget.range.head < this.viewport.from || this.scrollTarget.range.head > this.viewport.to) if (viewportChange) this.viewport = this.getViewport(bias, this.scrollTarget) this.updateForViewport() if ((result & UpdateFlag.Height) || viewportChange) this.updateViewportLines() if (this.lineGaps.length || this.viewport.to - this.viewport.from > (LG.Margin << 1)) this.updateLineGaps(this.ensureLineGaps(refresh ? [] : this.lineGaps, view)) result |= this.computeVisibleRanges() if (this.mustEnforceCursorAssoc) { this.mustEnforceCursorAssoc = false // This is done in the read stage, because moving the selection // to a line end is going to trigger a layout anyway, so it // can't be a pure write. It should be rare that it does any // writing. view.docView.enforceCursorAssoc() } return result } get visibleTop() { return this.scaler.fromDOM(this.pixelViewport.top) } get visibleBottom() { return this.scaler.fromDOM(this.pixelViewport.bottom) } getViewport(bias: number, scrollTarget: ScrollTarget | null): Viewport { // This will divide VP.Margin between the top and the // bottom, depending on the bias (the change in viewport position // since the last update). It'll hold a number between 0 and 1 let marginTop = 0.5 - Math.max(-0.5, Math.min(0.5, bias / VP.Margin / 2)) let map = this.heightMap, oracle = this.heightOracle let {visibleTop, visibleBottom} = this let viewport = new Viewport(map.lineAt(visibleTop - marginTop * VP.Margin, QueryType.ByHeight, oracle, 0, 0).from, map.lineAt(visibleBottom + (1 - marginTop) * VP.Margin, QueryType.ByHeight, oracle, 0, 0).to) // If scrollTarget is given, make sure the viewport includes that position if (scrollTarget) { let {head} = scrollTarget.range if (head < viewport.from || head > viewport.to) { let viewHeight = Math.min(this.editorHeight, this.pixelViewport.bottom - this.pixelViewport.top) let block = map.lineAt(head, QueryType.ByPos, oracle, 0, 0), topPos if (scrollTarget.y == "center") topPos = (block.top + block.bottom) / 2 - viewHeight / 2 else if (scrollTarget.y == "start" || scrollTarget.y == "nearest" && head < viewport.from) topPos = block.top else topPos = block.bottom - viewHeight viewport = new Viewport(map.lineAt(topPos - VP.Margin / 2, QueryType.ByHeight, oracle, 0, 0).from, map.lineAt(topPos + viewHeight + VP.Margin / 2, QueryType.ByHeight, oracle, 0, 0).to) } } return viewport } mapViewport(viewport: Viewport, changes: ChangeDesc) { let from = changes.mapPos(viewport.from, -1), to = changes.mapPos(viewport.to, 1) return new Viewport(this.heightMap.lineAt(from, QueryType.ByPos, this.heightOracle, 0, 0).from, this.heightMap.lineAt(to, QueryType.ByPos, this.heightOracle, 0, 0).to) } // Checks if a given viewport covers the visible part of the // document and not too much beyond that. viewportIsAppropriate({from, to}: Viewport, bias = 0) { if (!this.inView) return true let {top} = this.heightMap.lineAt(from, QueryType.ByPos, this.heightOracle, 0, 0) let {bottom} = this.heightMap.lineAt(to, QueryType.ByPos, this.heightOracle, 0, 0) let {visibleTop, visibleBottom} = this return (from == 0 || top <= visibleTop - Math.max(VP.MinCoverMargin, Math.min(-bias, VP.MaxCoverMargin))) && (to == this.state.doc.length || bottom >= visibleBottom + Math.max(VP.MinCoverMargin, Math.min(bias, VP.MaxCoverMargin))) && (top > visibleTop - 2 * VP.Margin && bottom < visibleBottom + 2 * VP.Margin) } mapLineGaps(gaps: readonly LineGap[], changes: ChangeSet) { if (!gaps.length || changes.empty) return gaps let mapped = [] for (let gap of gaps) if (!changes.touchesRange(gap.from, gap.to)) mapped.push(new LineGap(changes.mapPos(gap.from), changes.mapPos(gap.to), gap.size)) return mapped } // Computes positions in the viewport where the start or end of a // line should be hidden, trying to reuse existing line gaps when // appropriate to avoid unneccesary redraws. // Uses crude character-counting for the positioning and sizing, // since actual DOM coordinates aren't always available and // predictable. Relies on generous margins (see LG.Margin) to hide // the artifacts this might produce from the user. ensureLineGaps(current: readonly LineGap[], mayMeasure?: EditorView) { let wrapping = this.heightOracle.lineWrapping let margin = wrapping ? LG.MarginWrap : LG.Margin, halfMargin = margin >> 1, doubleMargin = margin << 1 // The non-wrapping logic won't work at all in predominantly right-to-left text. if (this.defaultTextDirection != Direction.LTR && !wrapping) return [] let gaps: LineGap[] = [] let addGap = (from: number, to: number, line: BlockInfo, structure: LineStructure) => { if (to - from < halfMargin) return let sel = this.state.selection.main, avoid = [sel.from] if (!sel.empty) avoid.push(sel.to) for (let pos of avoid) { if (pos > from && pos < to) { addGap(from, pos - LG.SelectionMargin, line, structure) addGap(pos + LG.SelectionMargin, to, line, structure) return } } let gap = find(current, gap => gap.from >= line.from && gap.to <= line.to && Math.abs(gap.from - from) < halfMargin && Math.abs(gap.to - to) < halfMargin && !avoid.some(pos => gap.from < pos && gap.to > pos)) if (!gap) { // When scrolling down, snap gap ends to line starts to avoid shifts in wrapping if (to < line.to && mayMeasure && wrapping && mayMeasure.visibleRanges.some(r => r.from <= to && r.to >= to)) { let lineStart = mayMeasure.moveToLineBoundary(EditorSelection.cursor(to), false, true).head if (lineStart > from) to = lineStart } gap = new LineGap(from, to, this.gapSize(line, from, to, structure)) } gaps.push(gap) } for (let line of this.viewportLines) { if (line.length < doubleMargin) continue let structure = lineStructure(line.from, line.to, this.stateDeco) if (structure.total < doubleMargin) continue let target = this.scrollTarget ? this.scrollTarget.range.head : null let viewFrom, viewTo if (wrapping) { let marginHeight = (margin / this.heightOracle.lineLength) * this.heightOracle.lineHeight let top, bot if (target != null) { let targetFrac = findFraction(structure, target) let spaceFrac = ((this.visibleBottom - this.visibleTop) / 2 + marginHeight) / line.height top = targetFrac - spaceFrac bot = targetFrac + spaceFrac } else { top = (this.visibleTop - line.top - marginHeight) / line.height bot = (this.visibleBottom - line.top + marginHeight) / line.height } viewFrom = findPosition(structure, top) viewTo = findPosition(structure, bot) } else { let totalWidth = structure.total * this.heightOracle.charWidth let marginWidth = margin * this.heightOracle.charWidth let left, right if (target != null) { let targetFrac = findFraction(structure, target) let spaceFrac = ((this.pixelViewport.right - this.pixelViewport.left) / 2 + marginWidth) / totalWidth left = targetFrac - spaceFrac right = targetFrac + spaceFrac } else { left = (this.pixelViewport.left - marginWidth) / totalWidth right = (this.pixelViewport.right + marginWidth) / totalWidth } viewFrom = findPosition(structure, left) viewTo = findPosition(structure, right) } if (viewFrom > line.from) addGap(line.from, viewFrom, line, structure) if (viewTo < line.to) addGap(viewTo, line.to, line, structure) } return gaps } gapSize(line: BlockInfo, from: number, to: number, structure: LineStructure) { let fraction = findFraction(structure, to) - findFraction(structure, from) if (this.heightOracle.lineWrapping) { return line.height * fraction } else { return structure.total * this.heightOracle.charWidth * fraction } } updateLineGaps(gaps: readonly LineGap[]) { if (!LineGap.same(gaps, this.lineGaps)) { this.lineGaps = gaps this.lineGapDeco = Decoration.set(gaps.map(gap => gap.draw(this, this.heightOracle.lineWrapping))) } } computeVisibleRanges() { let deco = this.stateDeco if (this.lineGaps.length) deco = deco.concat(this.lineGapDeco) let ranges: {from: number, to: number}[] = [] RangeSet.spans(deco, this.viewport.from, this.viewport.to, { span(from, to) { ranges.push({from, to}) }, point() {} }, 20) let changed = ranges.length != this.visibleRanges.length || this.visibleRanges.some((r, i) => r.from != ranges[i].from || r.to != ranges[i].to) this.visibleRanges = ranges return changed ? UpdateFlag.Viewport : 0 } lineBlockAt(pos: number): BlockInfo { return (pos >= this.viewport.from && pos <= this.viewport.to && this.viewportLines.find(b => b.from <= pos && b.to >= pos)) || scaleBlock(this.heightMap.lineAt(pos, QueryType.ByPos, this.heightOracle, 0, 0), this.scaler) } lineBlockAtHeight(height: number): BlockInfo { return scaleBlock(this.heightMap.lineAt(this.scaler.fromDOM(height), QueryType.ByHeight, this.heightOracle, 0, 0), this.scaler) } scrollAnchorAt(scrollTop: number) { let block = this.lineBlockAtHeight(scrollTop + 8) return block.from >= this.viewport.from || this.viewportLines[0].top - scrollTop > 200 ? block : this.viewportLines[0] } elementAtHeight(height: number): BlockInfo { return scaleBlock(this.heightMap.blockAt(this.scaler.fromDOM(height), this.heightOracle, 0, 0), this.scaler) } get docHeight() { return this.scaler.toDOM(this.heightMap.height) } get contentHeight() { return this.docHeight + this.paddingTop + this.paddingBottom } } export class Viewport { constructor(readonly from: number, readonly to: number) {} } type LineStructure = {total: number, ranges: {from: number, to: number}[]} function lineStructure(from: number, to: number, stateDeco: readonly DecorationSet[]): LineStructure { let ranges = [], pos = from, total = 0 RangeSet.spans(stateDeco, from, to, { span() {}, point(from, to) { if (from > pos) { ranges.push({from: pos, to: from}); total += from - pos } pos = to } }, 20) // We're only interested in collapsed ranges of a significant size if (pos < to) { ranges.push({from: pos, to}); total += to - pos } return {total, ranges} } function findPosition({total, ranges}: LineStructure, ratio: number): number { if (ratio <= 0) return ranges[0].from if (ratio >= 1) return ranges[ranges.length - 1].to let dist = Math.floor(total * ratio) for (let i = 0;; i++) { let {from, to} = ranges[i], size = to - from if (dist <= size) return from + dist dist -= size } } function findFraction(structure: LineStructure, pos: number) { let counted = 0 for (let {from, to} of structure.ranges) { if (pos <= to) { counted += pos - from break } counted += to - from } return counted / structure.total } function find(array: readonly T[], f: (value: T) => boolean): T | undefined { for (let val of array) if (f(val)) return val return undefined } // Convert between heightmap heights and DOM heights (see // VP.MaxDOMHeight) type YScaler = { toDOM(n: number): number fromDOM(n: number): number scale: number } // Don't scale when the document height is within the range of what // the DOM can handle. const IdScaler: YScaler = { toDOM(n: number) { return n }, fromDOM(n: number) { return n }, scale: 1 } // When the height is too big (> VP.MaxDOMHeight), scale down the // regions outside the viewports so that the total height is // VP.MaxDOMHeight. class BigScaler implements YScaler { scale: number viewports: {from: number, to: number, top: number, bottom: number, domTop: number, domBottom: number}[] constructor(oracle: HeightOracle, heightMap: HeightMap, viewports: readonly Viewport[]) { let vpHeight = 0, base = 0, domBase = 0 this.viewports = viewports.map(({from, to}) => { let top = heightMap.lineAt(from, QueryType.ByPos, oracle, 0, 0).top let bottom = heightMap.lineAt(to, QueryType.ByPos, oracle, 0, 0).bottom vpHeight += bottom - top return {from, to, top, bottom, domTop: 0, domBottom: 0} }) this.scale = (VP.MaxDOMHeight - vpHeight) / (heightMap.height - vpHeight) for (let obj of this.viewports) { obj.domTop = domBase + (obj.top - base) * this.scale domBase = obj.domBottom = obj.domTop + (obj.bottom - obj.top) base = obj.bottom } } toDOM(n: number) { for (let i = 0, base = 0, domBase = 0;; i++) { let vp = i < this.viewports.length ? this.viewports[i] : null if (!vp || n < vp.top) return domBase + (n - base) * this.scale if (n <= vp.bottom) return vp.domTop + (n - vp.top) base = vp.bottom; domBase = vp.domBottom } } fromDOM(n: number) { for (let i = 0, base = 0, domBase = 0;; i++) { let vp = i < this.viewports.length ? this.viewports[i] : null if (!vp || n < vp.domTop) return base + (n - domBase) / this.scale if (n <= vp.domBottom) return vp.top + (n - vp.domTop) base = vp.bottom; domBase = vp.domBottom } } } function scaleBlock(block: BlockInfo, scaler: YScaler): BlockInfo { if (scaler.scale == 1) return block let bTop = scaler.toDOM(block.top), bBottom = scaler.toDOM(block.bottom) return new BlockInfo(block.from, block.length, bTop, bBottom - bTop, Array.isArray(block._content) ? block._content.map(b => scaleBlock(b, scaler)) : block._content) } view-6.26.3/test/000077500000000000000000000000001460616253600135525ustar00rootroot00000000000000view-6.26.3/test/tempview.ts000066400000000000000000000022141460616253600157610ustar00rootroot00000000000000// Helper library for browser test scripts import {EditorView} from "@codemirror/view" import {EditorState, Extension} from "@codemirror/state" const workspace: HTMLElement = document.querySelector("#workspace")! as HTMLElement let currentTempView: EditorView | null = null let hide: any = null /// Create a hidden view with the given document and extensions that /// lives until the next call to `tempView`. export function tempView(doc = "", extensions: Extension = []): EditorView { if (currentTempView) { currentTempView.destroy() currentTempView = null } currentTempView = new EditorView({state: EditorState.create({doc, extensions})}) workspace.appendChild(currentTempView.dom) workspace.style.pointerEvents = "" if (hide == null) hide = setTimeout(() => { hide = null workspace.style.pointerEvents = "none" }, 100) return currentTempView } /// Focus the given view or raise an error when the window doesn't have focus. export function requireFocus(cm: EditorView): EditorView { if (!document.hasFocus()) throw new Error("The document doesn't have focus, which is needed for this test") cm.focus() return cm } view-6.26.3/test/test-heightmap.ts000066400000000000000000000350431460616253600170520ustar00rootroot00000000000000import {Decoration, WidgetType, BlockType, BlockInfo, __test} from "@codemirror/view" import {Text} from "@codemirror/state" import ist from "ist" const {HeightMap, HeightOracle, MeasuredHeights, QueryType, ChangedRange} = __test const byH = QueryType.ByHeight, byP = QueryType.ByPos function o(doc: Text) { return (new HeightOracle(false)).setDoc(doc) } describe("HeightMap", () => { it("starts empty", () => { let empty = HeightMap.empty() ist(empty.length, 0) ist(empty.size, 1) }) function mk(text: Text, deco: any = []) { return HeightMap.empty().applyChanges([Decoration.set(deco, true)], Text.empty, o(text), [new ChangedRange(0, 0, 0, text.length)]) } function doc(... lineLen: number[]) { let text = lineLen.map(len => "x".repeat(len)) return Text.of(text) } it("grows to match the document", () => { ist(mk(doc(10, 10, 8)).length, 30) }) class MyWidget extends WidgetType { constructor(readonly value: number) { super() } eq(other: MyWidget) { return this.value == other.value } toDOM() { return document.body } get estimatedHeight() { return this.value } } class NoHeightWidget extends WidgetType { toDOM() { return document.body } } it("separates lines with decorations on them", () => { let map = mk(doc(10, 10, 20, 5), [Decoration.widget({widget: new MyWidget(20)}).range(5), Decoration.replace({}).range(25, 46)]) ist(map.length, 48) ist(map.toString(), "line(10:20) gap(10) line(26-21)") }) it("ignores irrelevant decorations", () => { let map = mk(doc(10, 10, 20, 5), [Decoration.widget({widget: new NoHeightWidget}).range(5), Decoration.mark({class: "ahah"}).range(25, 46)]) ist(map.length, 48) ist(map.toString(), "gap(48)") }) it("drops decorations from the tree when they are deleted", () => { let text = doc(20) let map = mk(text, [Decoration.widget({widget: new MyWidget(20)}).range(5)]) ist(map.toString(), "line(20:20)") map = map.applyChanges([], text, o(text), [new ChangedRange(5, 5, 5, 5)]) ist(map.toString(), "line(20)") }) it("updates the length of replaced decorations for changes", () => { let text = doc(20) let map = mk(text, [Decoration.replace({}).range(5, 15)]) map = map.applyChanges([Decoration.set(Decoration.replace({}).range(5, 10))], text, o(text.replace(7, 12, Text.empty)), [new ChangedRange(7, 12, 7, 7)]) ist(map.toString(), "line(15-5)") }) it("stores information about block widgets", () => { let text = doc(3, 3, 3), oracle = o(text) let map = mk(text, [Decoration.widget({widget: new MyWidget(10), side: -1, block: true}).range(0), Decoration.widget({widget: new MyWidget(13), side: -1, block: true}).range(0), Decoration.widget({widget: new MyWidget(5), side: 1, block: true}).range(3)]) ist(map.toString(), "block(0)-block(0)-line(3)-block(0) gap(7)") ist(map.height, 28 + 3 * oracle.lineHeight) let {type} = map.lineAt(0, byP, oracle, 0, 0) ist((type as BlockInfo[]).map(b => b.height).join(), [10, 13, oracle.lineHeight, 5].join()) ist(map.lineAt(4, byP, oracle, 0, 0).top, 28 + oracle.lineHeight) map = map.updateHeight(oracle, 0, false, new MeasuredHeights(0, [8, 12, 10, 20, 40, 20])) ist(map.toString(), "block(0)-block(0)-line(3)-block(0) line(3) line(3)") ist(map.height, 110) }) it("stores information about block ranges", () => { let text = doc(3, 3, 3, 3, 3, 3) let map = mk(text, [Decoration.widget({widget: new MyWidget(10), side: -1, block: true}).range(4), Decoration.replace({widget: new MyWidget(40), block: true}).range(4, 11), Decoration.widget({widget: new MyWidget(15), side: 1, block: true}).range(11), // Widgets after the start or before the end of an inclusive range get covered Decoration.widget({widget: new MyWidget(20), side: 1, block: true}).range(16), Decoration.replace({widget: new MyWidget(50), block: true}).range(16, 19), Decoration.widget({widget: new MyWidget(10), side: -1, block: true}).range(19)]) ist(map.toString(), "gap(3) block(0)-block(7)-block(0) gap(3) block(3) gap(3)") map = map.updateHeight(o(text), 0, false, new MeasuredHeights(4, [5, 5, 5, 5, 10, 5])) ist(map.height, o(text).lineHeight + 35) }) it("handles empty lines correctly", () => { let text = doc(0, 0, 0, 0, 0) let map = mk(text, [Decoration.widget({widget: new MyWidget(10), side: -1, block: true}).range(1), Decoration.replace({widget: new MyWidget(20), block: true}).range(2, 2), Decoration.widget({widget: new MyWidget(30), side: 1, block: true}).range(3)]) ist(map.toString(), "gap(0) block(0)-line(0) block(0) line(0)-block(0) gap(0)") map = map.applyChanges([], text, o(text.replace(1, 3, Text.of(["y"]))), [new ChangedRange(1, 3, 1, 2)]) ist(map.toString(), "gap(3)") }) it("joins ranges", () => { let text = doc(10, 10, 10, 10) let map = mk(text, [Decoration.replace({}).range(16, 27)]) ist(map.toString(), "gap(10) line(21-11) gap(10)") map = map.applyChanges([], text, o(text.replace(5, 38, Text.of(["yyy"]))), [new ChangedRange(5, 38, 5, 8)]) ist(map.toString(), "gap(13)") }) it("joins lines", () => { let text = doc(10, 10, 10) let map = mk(text, [Decoration.replace({}).range(2, 5), Decoration.widget({widget: new MyWidget(20)}).range(24)]) ist(map.toString(), "line(10-3) gap(10) line(10:20)") map = map.applyChanges([ Decoration.set([Decoration.replace({}).range(2, 5), Decoration.widget({widget: new MyWidget(20)}).range(12)]) ], text, o(text.replace(10, 22, Text.empty)), [new ChangedRange(10, 22, 10, 10)]) ist(map.toString(), "line(20-3:20)") }) it("materializes lines for measured heights", () => { let text = doc(10, 10, 10, 10), oracle = o(text) let map = mk(text, []) .updateHeight(oracle, 0, false, new MeasuredHeights(11, [28, 14, 5])) ist(map.toString(), "gap(10) line(10) line(10) line(10)") ist(map.height, 61) }) it("doesn't set the heightChanged bit for small within-line replacements", () => { let text = doc(10, 10, 10), oracle = o(text) let map = mk(text, []).updateHeight(oracle, 0, false, new MeasuredHeights(0, [12, 12, 12])) let newText = text.replace(21, 21, Text.of(["!"])) map = map.applyChanges([], text, oracle.setDoc(newText), [new ChangedRange(21, 21, 21, 22)]) oracle.heightChanged = false map = map.updateHeight(oracle, 0, false, new MeasuredHeights(0, [12, 12, 12])) ist(oracle.heightChanged, false) }) it("can update lines across the tree", () => { let text = doc(...new Array(100).fill(10)), oracle = o(text) let map = mk(text).updateHeight(oracle, 0, false, new MeasuredHeights(0, new Array(100).fill(12))) ist(map.height, 1200) ist(map.size, 100) map = map.updateHeight(oracle, 0, false, new MeasuredHeights(55, new Array(90).fill(10))) ist(map.height, 1020) ist(map.size, 100) }) function depth(heightMap: any): number { let {left, right} = heightMap return left ? Math.max(depth(left), depth(right)) + 1 : 1 } it("balances a big tree", () => { let text = doc(...new Array(100).fill(30)), oracle = o(text) let map = mk(text).updateHeight(oracle, 0, false, new MeasuredHeights(0, new Array(100).fill(15))) ist(map.height, 1500) ist(map.size, 100) ist(depth(map), 9, "<") let text2 = text.replace(0, 31 * 80, Text.empty) map = map.applyChanges([], text, o(text2), [new ChangedRange(0, 31 * 80, 0, 0)]) ist(map.size, 20) ist(depth(map), 7, "<") let len = text2.length let text3 = text2.replace(len, len, Text.of("\nfoo".repeat(200).split("\n"))) map = map.applyChanges([], text2, o(text3), [new ChangedRange(len, len, len, len + 800)]) map = map.updateHeight(oracle.setDoc(text3), 0, false, new MeasuredHeights(len + 1, new Array(200).fill(10))) ist(map.size, 220) ist(depth(map), 12, "<") }) it("can handle inserting a line break", () => { let text = doc(3, 3, 3), oracle = o(text) let map = mk(text).updateHeight(oracle, 0, false, new MeasuredHeights(0, [10, 10, 10])) ist(map.size, 3) let text2 = text.replace(3, 3, Text.of(["", ""])) map = map.applyChanges([], text, oracle.setDoc(text2), [new ChangedRange(3, 3, 3, 4)]) .updateHeight(oracle, 0, false, new MeasuredHeights(0, [10, 10, 10, 10])) ist(map.size, 4) ist(map.height, 40) }) it("can handle insertion in the middle of a line", () => { let text = doc(3, 3, 3), oracle = o(text) let map = mk(text).updateHeight(oracle, 0, false, new MeasuredHeights(0, [10, 10, 10])) let text2 = text.replace(5, 5, Text.of(["foo", "bar", "baz", "bug"])) map = map.applyChanges([], text, o(text2), [new ChangedRange(5, 5, 5, 20)]) .updateHeight(o(text2), 0, false, new MeasuredHeights(0, [10, 10, 10, 10, 10, 10])) ist(map.size, 6) ist(map.height, 60) }) it("properly shrinks gaps when partially replaced", () => { let text = doc(9, 9, 9, 9, 9, 9, 9) let map = mk(text).updateHeight(o(text), 0, false, new MeasuredHeights(20, [10, 10, 10])) ist(map.toString(), "gap(19) line(9) line(9) line(9) gap(19)") map = map.applyChanges([Decoration.set(Decoration.replace({}).range(15, 55))], text, o(text), [new ChangedRange(15, 55, 15, 55)]) ist(map.toString(), "gap(9) line(49-40) gap(9)") }) describe("blockAt", () => { it("finds blocks in a gap", () => { let text = doc(3, 3, 3, 3, 3), map = mk(text) let block1 = map.blockAt(0, o(text), 0, 0) ist(block1.from, 0); ist(block1.to, 3) ist(block1.top, 0); ist(block1.bottom, 0, ">") ist(block1.type, BlockType.Text) let block2 = map.blockAt(block1.bottom + 1, o(text), 0, 0) ist(block2.from, 4); ist(block2.to, 7) ist(block2.top, block1.bottom); ist(block2.bottom, block1.bottom, ">") let block3 = map.blockAt(1e9, o(text), 0, 0) ist(block3.from, 16); ist(block3.to, 19) ist(block3.bottom, map.height) }) it("finds blocks in lines", () => { let text = doc(3, 3, 3, 3), map = mk(text).updateHeight(o(text), 0, false, new MeasuredHeights(0, [10, 20, 10, 30])) let oracle = o(text) let block1 = map.blockAt(-100, oracle, 0, 0) ist(block1.from, 0); ist(block1.to, 3) ist(block1.top, 0); ist(block1.bottom, 10) ist(block1.type, BlockType.Text) let block2 = map.blockAt(39, oracle, 0, 0) ist(block2.from, 8); ist(block2.to, 11) ist(block2.top, 30); ist(block2.bottom, 40) let block3 = map.blockAt(77, oracle, 0, 0) ist(block3.from, 12); ist(block3.to, 15) ist(block3.top, 40); ist(block3.bottom, 70) }) it("finds widget blocks", () => { let text = doc(3, 3, 3, 3) let map = mk(text, [Decoration.widget({widget: new MyWidget(100), block: true, side: -1}).range(4), Decoration.replace({widget: new MyWidget(30), block: true}).range(8, 11), Decoration.widget({widget: new MyWidget(0), block: true, side: 1}).range(15)]) let oracle = o(text) let block1 = map.blockAt(0, oracle, 0, 0) ist(block1.from, 0); ist(block1.to, 3) let block2 = map.blockAt(block1.height + 1, oracle, 0, 0) ist(block2.from, 4); ist(block2.to, 4) ist(block2.top, block1.height); ist(block2.height, 100) ist(block2.type, BlockType.WidgetBefore) let top3 = block2.bottom + block1.height let block3 = map.blockAt(top3 + 10, oracle, 0, 0) ist(block3.from, 8); ist(block3.to, 11) ist(block3.top, top3); ist(block3.height, 30) ist(block3.type, BlockType.WidgetRange) let block4 = map.blockAt(block3.bottom + block1.height, oracle, 0, 0) ist(block4.type, BlockType.WidgetAfter) }) }) function eqBlock(a: BlockInfo, b: BlockInfo) { return a.from == b.from && a.to == b.to && a.top == b.top && a.bottom == b.bottom } describe("lineAt", () => { it("finds lines in gaps", () => { let text = doc(3, 3, 3, 3), map = mk(text) let oracle = o(text) let line1 = map.lineAt(0, byP, oracle, 0, 0) ist(line1.from, 0); ist(line1.to, 3) ist(line1.top, 0) ist(map.lineAt(0, byH, oracle, 0, 0), line1, eqBlock) let line2 = map.lineAt(line1.to + 1, byP, oracle, 0, 0) ist(line2.from, 4); ist(line2.to, 7) ist(line2.top, line1.bottom) ist(map.lineAt(line1.bottom + 1, byH, oracle, 0, 0), line2, eqBlock) let line3 = map.lineAt(15, byP, oracle, 0, 0) ist(line3.from, 12); ist(line3.to, 15) ist(line3.bottom, map.height) ist(map.lineAt(1e9, byH, oracle, 0, 0), line3, eqBlock) }) it("finds lines in lines", () => { let text = doc(3, 3, 3, 3), oracle = o(text) let map = mk(text).updateHeight(oracle, 0, false, new MeasuredHeights(0, [10, 10, 20, 10])) let line1 = map.lineAt(0, byP, oracle, 0, 0) ist(line1.from, 0); ist(line1.to, 3) ist(line1.top, 0); ist(line1.bottom, 10) ist(map.lineAt(9, byH, oracle, 0, 0), line1, eqBlock) let line2 = map.lineAt(9, byP, oracle, 0, 0) ist(line2.from, 8); ist(line2.to, 11) ist(line2.top, 20); ist(line2.bottom, 40) ist(map.lineAt(39, byH, oracle, 0, 0), line2, eqBlock) }) it("includes adjacent widgets in lines", () => { let text = doc(3, 3, 3, 3), oracle = o(text) let map = mk(text, [Decoration.widget({widget: new MyWidget(100), block: true, side: -1}).range(4), Decoration.replace({widget: new MyWidget(30), block: true}).range(7, 8), Decoration.widget({widget: new MyWidget(0), block: true, side: 1}).range(15)]) let line1 = map.lineAt(4, byP, oracle, 0, 0) ist(line1.from, 4); ist(line1.to, 11) ist((line1.type as any[]).length, 4) ist(map.lineAt(line1.top + 1, byH, oracle, 0, 0), line1, eqBlock) ist(map.lineAt(line1.bottom - 1, byH, oracle, 0, 0), line1, eqBlock) ist(map.lineAt(line1.top + line1.height / 2, byH, oracle, 0, 0), line1, eqBlock) ist(map.lineAt(5, byP, oracle, 0, 0), line1, eqBlock) ist(map.lineAt(7, byP, oracle, 0, 0), line1, eqBlock) ist(map.lineAt(11, byP, oracle, 0, 0), line1, eqBlock) let line2 = map.lineAt(map.height, byH, oracle, 0, 0) ist(line2.from, 12); ist(line2.to, 15) ist((line2.type as any[]).length!, 2) ist(map.lineAt(line2.top + 1, byH, oracle, 0, 0), line2, eqBlock) }) }) }) view-6.26.3/test/webtest-bidi.ts000066400000000000000000000170341460616253600165110ustar00rootroot00000000000000import {tempView} from "./tempview.js" import ist from "ist" import {__test, BidiSpan, Direction, Decoration, DecorationSet, EditorView} from "@codemirror/view" import {Text, EditorSelection, SelectionRange, Range, StateField, Extension} from "@codemirror/state" function queryBrowserOrder(strings: readonly string[]) { let scratch = document.body.appendChild(document.createElement("div")) for (let str of strings) { let wrap = scratch.appendChild(document.createElement("div")) wrap.style.whiteSpace = "pre" for (let ch of str) { let span = document.createElement("span") span.textContent = ch wrap.appendChild(span) } } let ltr: (readonly number[])[] = [], rtl: (readonly number[])[] = [] for (let i = 0; i < 2; i++) { let dest = i ? rtl : ltr scratch.style.direction = i ? "rtl" : "ltr" for (let cur = scratch.firstChild; cur; cur = cur.nextSibling) { let positions = [] for (let sp = cur.firstChild, i = 0; sp; sp = sp.nextSibling) positions.push([i++, (sp as HTMLElement).offsetLeft]) dest.push(positions.sort((a, b) => a[1] - b[1]).map(x => x[0])) } } scratch.remove() return {ltr, rtl} } const cases = [ "codemirror", "كودالمرآة", "codeمرآة", "الشفرةmirror", "codeمرآةabc", "كود1234المرآة", "كودabcالمرآة", "كو,", "code123مرآة157abc", " foo ", " مرآة ", "ab12-34%م", "م1234%bc", "ر12:34ر", "xyאהxyאהxyאהxyאהxyאהxyאהxyאה", "ab مرآة10 cde 20مرآة!", "(ء)و)", "(([^ء-ي]|^)و)", "ء(و)", "[foo(barء)]" ] let queried: {ltr: (readonly number[])[], rtl: (readonly number[])[]} | null = null function getOrder(i: number, dir: Direction) { if (!queried) queried = queryBrowserOrder(cases) return queried[dir == Direction.LTR ? "ltr" : "rtl"][i] } function ourOrder(order: readonly BidiSpan[], dir: Direction) { let result = [] for (let span of dir == Direction.LTR ? order : order.slice().reverse()) { if (span.level % 2) for (let i = span.to - 1; i >= span.from; i--) result.push(i) else for (let i = span.from; i < span.to; i++) result.push(i) } return result } function tests(dir: Direction) { describe(Direction[dir] + " context", () => { for (let i = 0; i < cases.length; i++) it(cases[i], () => { ist(ourOrder(__test.computeOrder(cases[i], dir, []), dir).join("-"), getOrder(i, dir).join("-")) }) }) describe(Direction[dir] + " motion", () => { for (let i = 0; i < cases.length; i++) { for (let forward = true;; forward = false) { it(cases[i] + (forward ? " forward" : " backward"), () => { let order = __test.computeOrder(cases[i], dir, []) let line = Text.of([cases[i]]).line(1) let seen = new Set() let span = order[forward ? 0 : order.length - 1] let pos = EditorSelection.cursor(span.side(!forward, dir), span.forward(forward, dir) ? 1 : -1) for (;;) { let id = pos.head * (pos.assoc < 0 ? -1 : 1) ist(!seen.has(id)) seen.add(id) let next = __test.moveVisually(line, order, dir, pos, forward) if (!next) break pos = next } ist(seen.size, cases[i].length + 1) }) if (!forward) break } } it("handles extending characters", () => { let str = "aé̠őx 😎🙉 👨‍🎤💪🏽👩‍👩‍👧‍👦 🇩🇪🇫🇷" let points = [0, 1, 4, 6, 7, 8, 10, 12, 13, 18, 22, 33, 34, 38, 42] let line = Text.of([str]).line(1) let order = __test.computeOrder(str, Direction.LTR, []) for (let i = 1; i < points.length; i++) { ist(__test.moveVisually(line, order, Direction.LTR, EditorSelection.cursor(points[i - 1], 0, 0), true)!.from, points[i]) ist(__test.moveVisually(line, order, Direction.LTR, EditorSelection.cursor(points[i], 0, 0), false)!.from, points[i - 1]) } }) it("handles a misplaced non-joiner without going in a loop", () => { let doc = "ءAB\u200cء", line = Text.of([doc]).line(1) let order = __test.computeOrder(doc, Direction.RTL, []) for (let pos: SelectionRange | null = EditorSelection.cursor(0), count = 0; count++;) { ist(count, 6, "<") pos = __test.moveVisually(line, order, Direction.RTL, pos, true) if (!pos) break } for (let pos: SelectionRange | null = EditorSelection.cursor(doc.length), count = 0; count++;) { ist(count, 6, "<") pos = __test.moveVisually(line, order, Direction.RTL, pos, false) if (!pos) break } }) }) } const rtlTheme = EditorView.theme({"&": {direction: "rtl"}}) const rtlIso = Decoration.mark({ attributes: {style: "direction: rtl; unicode-bidi: isolate"}, bidiIsolate: Direction.RTL }) const ltrIso = Decoration.mark({ attributes: {style: "direction: ltr; unicode-bidi: isolate"}, bidiIsolate: Direction.LTR }) function deco(...decorations: Range[]) { return StateField.define({ create: () => Decoration.set(decorations), update: (s, tr) => s.map(tr.changes), provide: f => [EditorView.decorations.from(f), EditorView.bidiIsolatedRanges.from(f)] }) } function testIsolates(doc: string, extensions: Extension, expected: string) { let cm = tempView(doc, [extensions]) cm.measure() ist(cm.bidiSpans(cm.state.doc.line(1)).map(s => s.from + "-" + s.to + ":" + s.level).join(" "), expected) } describe("bidi", () => { tests(Direction.LTR) tests(Direction.RTL) it("properly handles isolates in RTL", () => { testIsolates("אחת -hello- שתיים", [rtlTheme, deco(ltrIso.range(4, 11))], "0-4:1 4-11:2 11-17:1") }) it("properly handles isolates in LTR", () => { testIsolates("one -שלום- two", deco(rtlIso.range(4, 10)), "0-4:0 4-10:1 10-14:0") }) it("properly handles isolates in RTL text in LTR context", () => { testIsolates("אחת -hello- שתיים", [deco(ltrIso.range(4, 11))], "11-17:1 4-11:2 0-4:1") }) it("handles LTR isolates in nested numerals", () => { testIsolates("كود12ab34المرآة", [rtlTheme, deco(ltrIso.range(5, 7))], "0-3:1 3-5:2 5-7:2 7-9:2 9-15:1") }) it("handles RTL isolates in nested numerals", () => { testIsolates("كود12مر34المرآة", [rtlTheme, deco(rtlIso.range(5, 7))], "0-3:1 3-5:2 5-7:1 7-9:2 9-15:1") }) it("works for multiple isolates", () => { testIsolates("one -שלום- two .אחת. three", [deco(rtlIso.range(4, 10), rtlIso.range(15, 20))], "0-4:0 4-10:1 10-15:0 15-20:1 20-26:0") }) it("handles multiple isolates in a row", () => { testIsolates("one -שלום- two", deco(rtlIso.range(4, 7), rtlIso.range(7, 10)), "0-4:0 4-7:1 7-10:1 10-14:0") }) it("supports nested isolates", () => { testIsolates("one -אחת .two. שתיים- three", [ deco(ltrIso.range(9, 14)), deco(rtlIso.range(4, 21)) ], "0-4:0 14-21:1 9-14:2 4-9:1 21-27:0") }) it("includes isolates at the end of spans in the base direction", () => { testIsolates("oneאחת -", deco(ltrIso.range(6, 7), ltrIso.range(7, 8)), "0-3:0 3-6:1 6-7:0 7-8:0") }) it("normalizes neutrals between isolates", () => { testIsolates("שלa-bום", deco(ltrIso.range(2, 3), ltrIso.range(4, 5)), "5-7:1 4-5:2 3-4:1 2-3:2 0-2:1") }) it("matches brackets across isolates", () => { testIsolates("one(אחת)שתיים", deco(rtlIso.range(4, 5)), "0-4:0 4-5:1 5-7:1 7-8:0 8-13:1") }) }) view-6.26.3/test/webtest-composition.ts000066400000000000000000000364721460616253600201540ustar00rootroot00000000000000import {tempView, requireFocus} from "./tempview.js" import {EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, WidgetType} from "@codemirror/view" import {EditorState, EditorSelection, StateField, Range} from "@codemirror/state" import ist from "ist" function event(cm: EditorView, type: string) { cm.contentDOM.dispatchEvent(new CompositionEvent(type)) } function up(node: Text, text: string = "", from = node.nodeValue!.length, to = from) { let val = node.nodeValue! node.nodeValue = val.slice(0, from) + text + val.slice(to) document.getSelection()!.collapse(node, from + text.length) return node } function hasCompositionDeco(cm: EditorView) { return !!cm.docView.hasComposition } function compose(cm: EditorView, start: () => Text, update: ((node: Text) => void)[], options: {end?: (node: Text) => void, cancel?: boolean} = {}) { event(cm, "compositionstart") let node!: Text, sel = document.getSelection()! for (let i = -1; i < update.length; i++) { if (i < 0) node = start() else update[i](node) let {focusNode, focusOffset} = sel let stack = [] for (let p = node.parentNode; p && p != cm.contentDOM; p = p.parentNode) stack.push(p) cm.observer.flush() if (options.cancel && i == update.length - 1) { ist(!hasCompositionDeco(cm)) } else { for (let p = node.parentNode, i = 0; p && p != cm.contentDOM && i < stack.length; p = p.parentNode, i++) ist(p, stack[i]) ist(node.parentNode && cm.contentDOM.contains(node.parentNode)) ist(sel.focusNode, focusNode) ist(sel.focusOffset, focusOffset) ist(hasCompositionDeco(cm)) } } event(cm, "compositionend") if (options.end) options.end(node) cm.observer.flush() cm.update([]) ist(!cm.composing) ist(!hasCompositionDeco(cm)) } function wordDeco(state: EditorState): DecorationSet { let re = /\w+/g, m, deco = [], text = state.doc.toString() while (m = re.exec(text)) deco.push(Decoration.mark({class: "word"}).range(m.index, m.index + m[0].length)) return Decoration.set(deco) } const wordHighlighter = EditorView.decorations.compute(["doc"], wordDeco) function deco(deco: readonly Range[]) { return ViewPlugin.define(() => ({ decorations: Decoration.set(deco), update(update: ViewUpdate) { this.decorations = this.decorations.map(update.changes) } }), {decorations: v => v.decorations}) } function widgets(positions: number[], sides: number[]): ViewPlugin { let xWidget = new class extends WidgetType { toDOM() { let s = document.createElement("var"); s.textContent = "×"; return s } } return deco(positions.map((p, i) => Decoration.widget({widget: xWidget, side: sides[i]}).range(p))) } describe("Composition", () => { it("supports composition on an empty line", () => { let cm = requireFocus(tempView("foo\n\nbar")) compose(cm, () => up(cm.domAtPos(4).node.appendChild(document.createTextNode("a"))), [ n => up(n, "b"), n => up(n, "c") ]) ist(cm.state.doc.toString(), "foo\nabc\nbar") }) it("supports composition at end of line in existing node", () => { let cm = requireFocus(tempView("foo")) compose(cm, () => up(cm.domAtPos(2).node as Text), [ n => up(n, "!"), n => up(n, "?") ]) ist(cm.state.doc.toString(), "foo!?") }) it("supports composition at end of line in a new node", () => { let cm = requireFocus(tempView("foo")) compose(cm, () => up(cm.contentDOM.firstChild!.appendChild(document.createTextNode("!"))), [ n => up(n, "?") ]) ist(cm.state.doc.toString(), "foo!?") }) it("supports composition at start of line in a new node", () => { let cm = requireFocus(tempView("foo")) compose(cm, () => { let l0 = cm.contentDOM.firstChild! return up(l0.insertBefore(document.createTextNode("!"), l0.firstChild)) }, [ n => up(n, "?") ]) ist(cm.state.doc.toString(), "!?foo") }) it("supports composition inside existing text", () => { let cm = requireFocus(tempView("foo")) compose(cm, () => up(cm.domAtPos(2).node as Text), [ n => up(n, "x", 1), n => up(n, "y", 2), n => up(n, "z", 3) ]) ist(cm.state.doc.toString(), "fxyzoo") }) it("can deal with Android-style newline-after-composition", () => { let cm = requireFocus(tempView("abcdef")) compose(cm, () => up(cm.domAtPos(2).node as Text), [ n => up(n, "x", 3), n => up(n, "y", 4) ], {end: n => { let line = n.parentNode!.appendChild(document.createElement("div")) line.textContent = "def" n.nodeValue = "abcxy" document.getSelection()!.collapse(line, 0) }}) ist(cm.state.doc.toString(), "abcxy\ndef") }) it("handles replacement of existing words", () => { let cm = requireFocus(tempView("one two three")) compose(cm, () => up(cm.domAtPos(1).node as Text, "five", 4, 7), [ n => up(n, "seven", 4, 8), n => up(n, "zero", 4, 9) ]) ist(cm.state.doc.toString(), "one zero three") }) it("doesn't get interrupted by changes in decorations", () => { let cm = requireFocus(tempView("foo ...", [wordHighlighter])) compose(cm, () => up(cm.domAtPos(5).node as Text), [ n => up(n, "hi", 1, 4) ]) ist(cm.state.doc.toString(), "foo hi") }) it("works inside highlighted text", () => { let cm = requireFocus(tempView("one two", [wordHighlighter])) compose(cm, () => up(cm.domAtPos(1).node as Text, "x"), [ n => up(n, "y"), n => up(n, ".") ]) ist(cm.state.doc.toString(), "onexy. two") }) it("can handle compositions spanning multiple tokens", () => { let cm = requireFocus(tempView("one two", [wordHighlighter])) compose(cm, () => up(cm.domAtPos(5).node as Text, "a"), [ n => up(n, "b"), n => up(n, "c") ], {end: n => { ;(n.parentNode!.previousSibling! as ChildNode).remove() ;(n.parentNode!.previousSibling! as ChildNode).remove() return up(n, "xyzone ", 0) }}) ist(cm.state.doc.toString(), "xyzone twoabc") }) it("doesn't overwrite widgets next to the composition", () => { let cm = requireFocus(tempView("", [widgets([0, 0], [-1, 1])])) compose(cm, () => { let l0 = cm.domAtPos(0).node return up(l0.insertBefore(document.createTextNode("a"), l0.lastChild)) }, [n => up(n, "b", 0, 1)], {end: () => { ist(cm.contentDOM.querySelectorAll("var").length, 2) }}) ist(cm.state.doc.toString(), "b") }) it("works for composition in the middle of a mark", () => { let cm = requireFocus(tempView("one three", [wordHighlighter, deco([Decoration.mark({class: "a"}).range(0, 9)])])) compose(cm, () => up(cm.domAtPos(4).node as Text, "-"), [n => { let a = n.parentNode as HTMLElement ist(a.className, "a") ist(a.innerHTML, 'one -three') return up(n, ".") }]) ist(cm.state.doc.toString(), "one -.three") }) it("works when composition rewraps the middle of a mark", () => { let cm = requireFocus(tempView("one three", [wordHighlighter, deco([Decoration.mark({class: "a"}).range(0, 9)])])) compose(cm, () => { let space = cm.domAtPos(4).node as Text, a = space.parentNode as HTMLElement let wrap1 = a.cloneNode(), wrap2 = a.cloneNode() wrap2.appendChild(a.lastChild!) a.parentNode!.insertBefore(wrap2, a.nextSibling) wrap1.appendChild(space) a.parentNode!.insertBefore(wrap1, a.nextSibling) return up(space, "-") }, [n => { let a = n.parentNode as HTMLElement ist(a.className, "a") ist(a.innerHTML, 'one -three') return up(n, ".") }]) ist(cm.state.doc.toString(), "one -.three") }) it("cancels composition when a change fully overlaps with it", () => { let cm = requireFocus(tempView("one\ntwo\nthree")) compose(cm, () => up(cm.domAtPos(5).node as Text, "x"), [ () => cm.dispatch({changes: {from: 2, to: 10, insert: "---"}}) ], {cancel: true}) ist(cm.state.doc.toString(), "on---hree") }) it("cancels composition when a change partially overlaps with it", () => { let cm = requireFocus(tempView("one\ntwo\nthree")) compose(cm, () => up(cm.domAtPos(5).node as Text, "x", 0), [ () => cm.dispatch({changes: {from: 5, to: 12, insert: "---"}}) ], {cancel: true}) ist(cm.state.doc.toString(), "one\nx---ee") }) it("cancels composition when a change happens inside of it", () => { let cm = requireFocus(tempView("one\ntwo\nthree")) compose(cm, () => up(cm.domAtPos(5).node as Text, "x", 0), [ () => cm.dispatch({changes: {from: 5, to: 6, insert: "!"}}) ], {cancel: true}) ist(cm.state.doc.toString(), "one\nx!wo\nthree") }) it("doesn't cancel composition when a change happens elsewhere", () => { let cm = requireFocus(tempView("one\ntwo\nthree")) compose(cm, () => up(cm.domAtPos(5).node as Text, "x", 0), [ n => up(n, "y", 1), () => cm.dispatch({changes: {from: 1, to: 2, insert: "!"}}), n => up(n, "z", 2) ]) ist(cm.state.doc.toString(), "o!e\nxyztwo\nthree") }) it("doesn't cancel composition when the composition is moved into a new line", () => { let cm = requireFocus(tempView("one\ntwo three", [wordHighlighter])) compose(cm, () => up(cm.domAtPos(9).node as Text, "x"), [ n => up(n, "y"), () => cm.dispatch({changes: {from: 4, insert: "\n"}}), n => up(n, "z") ]) ist(cm.state.doc.toString(), "one\n\ntwo threexyz") }) it("doesn't cancel composition when a line break is inserted in front of it", () => { let cm = requireFocus(tempView("one two three", [wordHighlighter])) compose(cm, () => up(cm.domAtPos(9).node as Text, "x"), [ n => up(n, "y"), () => cm.dispatch({changes: {from: 8, insert: "\n"}}), n => up(n, "z") ]) ist(cm.state.doc.toString(), "one two \nthreexyz") }) it("works before a block widget", () => { let widget = new class extends WidgetType { toDOM() { let d = document.createElement("div"); d.textContent = "---"; return d } } let cm = requireFocus(tempView("abcd", [deco([Decoration.widget({widget, side: 1}).range(2)])])) compose(cm, () => up(cm.domAtPos(1).node as Text, "p"), [n => up(n, "q"), n => up(n, "r")]) ist(cm.state.doc.toString(), "abpqrcd") }) it("properly handles line break insertion at end of composition", () => { let cm = requireFocus(tempView("one two three", [wordHighlighter])) compose(cm, () => up(cm.domAtPos(5).node as Text, "o"), [ () => cm.dispatch({changes: {from: 8, insert: "\n"}}) ]) ist(cm.state.doc.toString(), "one twoo\n three") }) it("can handle browsers inserting new wrapper nodes around the composition", () => { let cm = requireFocus(tempView("one two", [wordHighlighter])) compose(cm, () => { let span = cm.domAtPos(1).node.parentNode! let wrap = span.appendChild(document.createElement("font")) let text = wrap.appendChild(document.createTextNode("")) return up(text, "1") }, [n => up(n, "2")]) ist(cm.state.doc.toString(), "one12 two") }) it("can handle siblings being moved into a new wrapper", () => { let cm = requireFocus(tempView("one two", [wordHighlighter])) compose(cm, () => { let span = cm.domAtPos(1).node.parentNode as HTMLElement let wrap = span.parentNode!.insertBefore(document.createElement("font"), span) wrap.appendChild(span.cloneNode(true)) span.remove() let text = wrap.appendChild(document.createTextNode("")) return up(text, "1") }, [n => up(n, "2")]) ist(cm.state.doc.toString(), "one12 two") }) it("doesn't cancel composition when a newline is added immediately in front", () => { let cm = requireFocus(tempView("one\ntwo three", [wordHighlighter])) compose(cm, () => up(cm.domAtPos(9).node as Text, "x"), [ n => up(n, "y"), () => cm.dispatch({changes: {from: 7, to: 8, insert: "\n"}}), n => up(n, "z") ]) ist(cm.state.doc.toString(), "one\ntwo\nthreexyz") }) it("handles compositions rapidly following each other", () => { let cm = requireFocus(tempView("one\ntwo")) event(cm, "compositionstart") let one = cm.domAtPos(1).node as Text up(one, "!") cm.observer.flush() event(cm, "compositionend") one.nodeValue = "one!!" let L2 = cm.contentDOM.lastChild event(cm, "compositionstart") let two = cm.domAtPos(7).node as Text ist(cm.contentDOM.lastChild, L2) up(two, ".") cm.observer.flush() ist(hasCompositionDeco(cm)) ist(getSelection()!.focusNode, two) ist(getSelection()!.focusOffset, 4) ist(cm.composing) event(cm, "compositionend") cm.observer.flush() ist(cm.state.doc.toString(), "one!!\ntwo.") }) it("applies compositions at secondary cursors", () => { let cm = requireFocus(tempView("one\ntwo", EditorState.allowMultipleSelections.of(true))) cm.dispatch({selection: EditorSelection.create([EditorSelection.cursor(3), EditorSelection.cursor(7)], 0)}) compose(cm, () => up(cm.domAtPos(2).node as Text, "·"), [ n => up(n, "-", 3, 4), n => up(n, "→", 3, 4) ]) ist(cm.state.doc.toString(), "one→\ntwo→") }) it("applies compositions at secondary cursors even when the change is before the cursor", () => { let cm = requireFocus(tempView("one\ntwo", EditorState.allowMultipleSelections.of(true))) cm.dispatch({selection: EditorSelection.create([EditorSelection.cursor(3), EditorSelection.cursor(7)], 0)}) compose(cm, () => up(cm.domAtPos(2).node as Text, "X"), [ n => up(n, "Y"), n => up(n, "Z", 3, 4) ]) ist(cm.state.doc.toString(), "oneZY\ntwoZY") }) it("doesn't try to apply multi-cursor composition in a single node", () => { let cm = requireFocus(tempView("onetwo")) cm.dispatch({selection: EditorSelection.create([EditorSelection.cursor(3), EditorSelection.cursor(6)], 0)}) compose(cm, () => up(cm.domAtPos(2).node as Text, "X", 3), [ n => up(n, "Y", 4), ]) ist(cm.state.doc.toString(), "oneXYtwo") }) it("can handle IME merging spans", () => { let field = StateField.define({ create: () => Decoration.set([ Decoration.mark({class: "a"}).range(0, 1), Decoration.mark({class: "b"}).range(1, 6), ]), update(deco, u) { return deco.map(u.changes) }, provide: f => EditorView.decorations.from(f) }) let cm = requireFocus(tempView("(hello)", [field])) cm.dispatch({selection: {anchor: 1, head: 6}}) compose(cm, () => up(cm.domAtPos(2).node as Text, "a"), [ n => { let sA = cm.contentDOM.querySelector(".a")!, sB = cm.contentDOM.querySelector(".b")! sB.remove() ;(sA as HTMLElement).innerText = "" sA.appendChild(document.createTextNode("(")) sA.appendChild(n) n.nodeValue = "a b" document.getSelection()!.collapse(n, 1) document.getSelection()!.extend(n, 3) }, n => up(n, "阿波", 0, 3) ]) ist(cm.state.doc.toString(), "(阿波)") }) it("can handle IME extending text nodes", () => { let cm = requireFocus(tempView("x .a.", [wordHighlighter])) compose(cm, () => { let dot = cm.contentDOM.firstChild!.lastChild! as Text dot.textContent = " .a.." dot.previousSibling!.remove() dot.previousSibling!.remove() document.getSelection()!.collapse(dot, 5) return dot }, []) ist(cm.contentDOM.textContent, "x .a..") }) }) view-6.26.3/test/webtest-coords.ts000066400000000000000000000204641460616253600170740ustar00rootroot00000000000000import {tempView} from "./tempview.js" import {EditorView, Decoration, WidgetType} from "@codemirror/view" import {Range} from "@codemirror/state" import ist from "ist" const inline = new class extends WidgetType { toDOM() { let span = document.createElement("span") span.className = "widget" span.textContent = "X" return span } } const block = new class extends WidgetType { toDOM() { let span = document.createElement("div") span.className = "widget" span.textContent = "X" return span } } function deco(...deco: Range[]) { return EditorView.decorations.of(Decoration.set(deco)) } describe("EditorView coords", () => { it("can find coordinates for simple text", () => { let cm = tempView("one two\n\nthree"), prev = null for (let i = 0; i < cm.state.doc.length; i++) { let coords = cm.coordsAtPos(i)! if (prev) ist(prev.top < coords.top - 5 || prev.left < coords.left) prev = coords ist(cm.posAtCoords({x: coords.left, y: coords.top + 1}), i) } }) it("can find coordinates in text scrolled into view horizontally", () => { let cm = tempView("l1\n" + "l2 ".repeat(400)) let rect = cm.dom.getBoundingClientRect(), line2 = cm.coordsAtPos(3)!.top + 2 cm.scrollDOM.scrollLeft = 0 let right = cm.posAtCoords({x: rect.right - 2, y: line2}) cm.scrollDOM.scrollLeft = (rect.right - rect.left) - 10 ist(cm.posAtCoords({x: rect.right - 2, y: line2}), right, ">") }) function near(a: number, b: any) { return Math.abs(a - b) < 5 } it("takes coordinates before side=1 widgets", () => { let widget = Decoration.widget({widget: inline, side: 1}) let cm = tempView("abdefg", [deco(widget.range(0), widget.range(3), widget.range(6))]) let sides = Array.prototype.map.call(cm.contentDOM.querySelectorAll(".widget"), w => w.getBoundingClientRect().left) ist(near(cm.coordsAtPos(0, 1)!.left, sides[0])) ist(near(cm.coordsAtPos(0, -1)!.left, sides[0])) ist(near(cm.coordsAtPos(3, 1)!.left, sides[1])) ist(near(cm.coordsAtPos(3, -1)!.left, sides[1])) ist(near(cm.coordsAtPos(6, 1)!.left, sides[2])) ist(near(cm.coordsAtPos(6, -1)!.left, sides[2])) }) it("takes coordinates after side=-1 widgets", () => { let widget = Decoration.widget({widget: inline, side: -1}) let cm = tempView("abdefg", [deco(widget.range(0), widget.range(3), widget.range(6))]) let sides = Array.prototype.map.call(cm.contentDOM.querySelectorAll(".widget"), w => w.getBoundingClientRect().right) ist(near(cm.coordsAtPos(0, 1)!.left, sides[0])) ist(near(cm.coordsAtPos(0, -1)!.left, sides[0])) ist(near(cm.coordsAtPos(3, 1)!.left, sides[1])) ist(near(cm.coordsAtPos(3, -1)!.left, sides[1])) ist(near(cm.coordsAtPos(6, 1)!.left, sides[2])) ist(near(cm.coordsAtPos(6, -1)!.left, sides[2])) }) it("respects sides for widgets wrapped in marks", () => { let cm = tempView("a\n\nb\n\nd", [ deco(Decoration.widget({widget: inline, side: 1}).range(2), Decoration.widget({widget: inline, side: -1}).range(5)), deco(Decoration.mark({class: "test"}).range(0, 7)) ]) let widgets = cm.contentDOM.querySelectorAll(".widget") let pos2 = widgets[0].getBoundingClientRect().left ist(near(cm.coordsAtPos(2, 1)!.left, pos2)) ist(near(cm.coordsAtPos(2, -1)!.left, pos2)) let pos5 = widgets[1].getBoundingClientRect().right ist(near(cm.coordsAtPos(5, 1)!.left, pos5)) ist(near(cm.coordsAtPos(5, -1)!.left, pos5)) }) it("takes coordinates between widgets", () => { let wb = Decoration.widget({widget: inline, side: -1}) let wa = Decoration.widget({widget: inline, side: 1}) let cm = tempView("abdefg", [deco(wb.range(0), wa.range(0), wb.range(3), wa.range(3), wb.range(6), wa.range(6))]) let sides = Array.prototype.map.call(cm.contentDOM.querySelectorAll(".widget"), w => w.getBoundingClientRect().right) ist(near(cm.coordsAtPos(0, 1)!.left, sides[0])) ist(near(cm.coordsAtPos(0, -1)!.left, sides[0])) ist(near(cm.coordsAtPos(3, 1)!.left, sides[2])) ist(near(cm.coordsAtPos(3, -1)!.left, sides[2])) ist(near(cm.coordsAtPos(6, 1)!.left, sides[4])) ist(near(cm.coordsAtPos(6, -1)!.left, sides[4])) }) it("takes coordinates before side=1 block widgets", () => { let widget = Decoration.widget({widget: block, side: 1, block: true}) let cm = tempView("ab", [deco(widget.range(0), widget.range(1), widget.range(2))]) let sides = Array.from(cm.contentDOM.querySelectorAll(".widget")).map(w => w.getBoundingClientRect().top) ist(near(cm.coordsAtPos(0, -1)!.bottom, sides[0])) ist(near(cm.coordsAtPos(0, 1)!.bottom, sides[1])) ist(near(cm.coordsAtPos(1, -1)!.bottom, sides[1])) ist(near(cm.coordsAtPos(1, 1)!.bottom, sides[2])) ist(near(cm.coordsAtPos(2, -1)!.bottom, sides[2])) ist(near(cm.coordsAtPos(2, 1)!.bottom, sides[2])) }) it("takes coordinates after side=-1 block widgets", () => { let widget = Decoration.widget({widget: block, side: -1, block: true}) let cm = tempView("ab", [deco(widget.range(0), widget.range(1), widget.range(2))]) let sides = Array.prototype.map.call(cm.contentDOM.querySelectorAll(".widget"), w => w.getBoundingClientRect().bottom) ist(near(cm.coordsAtPos(0, -1)!.top, sides[0])) ist(near(cm.coordsAtPos(0, 1)!.top, sides[0])) ist(near(cm.coordsAtPos(1, -1)!.top, sides[0])) ist(near(cm.coordsAtPos(1, 1)!.top, sides[1])) ist(near(cm.coordsAtPos(2, -1)!.top, sides[1])) ist(near(cm.coordsAtPos(2, 1)!.top, sides[2])) }) it("takes coordinates around non-inclusive block widgets", () => { let widget = Decoration.replace({widget: block, inclusive: false, block: true}) let cm = tempView("ab", [deco(widget.range(0, 2))]) let rect = cm.contentDOM.querySelector(".widget")!.getBoundingClientRect() ist(near(cm.coordsAtPos(0, 1)!.bottom, rect.top)) ist(near(cm.coordsAtPos(2, -1)!.top, rect.bottom)) }) it("takes proper coordinates for elements on decoration boundaries", () => { let cm = tempView("a b c", [deco(Decoration.mark({attributes: {style: "padding: 0 10px"}}).range(2, 3))]) ist(near(cm.coordsAtPos(2, 1)!.left, cm.coordsAtPos(2, -1)!.left + 10)) ist(near(cm.coordsAtPos(3, -1)!.left, cm.coordsAtPos(3, 1)!.left - 10)) }) }) describe("coordsForChar", () => { function near(a: number, b: number) { return Math.abs(a - b) < 0.01 } it("returns reasonable coords", () => { let cm = tempView("abc\ndef") let a = cm.coordsForChar(0)!, c = cm.coordsForChar(2)!, d = cm.coordsForChar(4)!, f = cm.coordsForChar(6)! ist(a.right, a.left, ">") ist(a.bottom, a.top, ">") ist(a.top, c.top, near) ist(a.bottom, c.bottom, near) ist(c.left, a.right, ">") ist(a.bottom, d.bottom, "<") ist(d.top, f.top, near) ist(f.left, c.left, near) }) it("returns null for non-rendered characters", () => { let cm = tempView("abc\ndef\n", [deco(Decoration.replace({}).range(1, 2))]) ist(cm.coordsForChar(1), null) ist(cm.coordsForChar(3), null) ist(cm.coordsForChar(8), null) }) it("returns proper rectangles in right-to-left text", () => { let cm = tempView("شاهد") let first = cm.coordsForChar(0)!, last = cm.coordsForChar(cm.state.doc.length - 1)! ist(first.left, first.right, "<") ist(last.left, last.right, "<") ist(first.left, last.right, ">") }) it("doesn't include space taken up by widgets", () => { let cm = tempView("abc", [deco(Decoration.widget({widget: inline, side: 1}).range(1), Decoration.widget({widget: inline, side: -1}).range(2))]) let a = cm.coordsForChar(0)!, b = cm.coordsForChar(1)!, c = cm.coordsForChar(2)! let ws = cm.contentDOM.querySelectorAll(".widget") as NodeListOf let w1 = ws[0].getBoundingClientRect(), w2 = ws[1].getBoundingClientRect() let ε = 0.01 ist(a.right - ε, w1.left, "<=") ist(b.left + ε, w1.right, ">=") ist(b.right - ε, w2.left, "<=") ist(c.left + ε, w2.right, ">=") }) it("returns positions for wrap points", () => { let cm = tempView("aaaaaaaaa bbbbbbbbbbbbb", [EditorView.theme({"&": {maxWidth: "7em"}}), EditorView.lineWrapping]) let a = cm.coordsForChar(0)!, b = cm.coordsForChar(10)!, wrap = cm.coordsForChar(9)! ist(a.top, b.top, "<") ist(wrap) ist(wrap.top, a.top, near) ist(wrap.top, b.top, "<") ist(wrap.left, a.right, ">") }) }) view-6.26.3/test/webtest-direction.ts000066400000000000000000000016741460616253600175650ustar00rootroot00000000000000import ist from "ist" import {Direction, EditorView, Decoration} from "@codemirror/view" import {StateEffect} from "@codemirror/state" import {tempView} from "./tempview.js" describe("EditorView text direction", () => { it("notices the text direction", () => { let cm = tempView("hi", [EditorView.theme({".cm-content": {direction: "rtl"}})]) cm.measure() ist(cm.textDirection, Direction.RTL) }) it("can compute direction per-line", () => { let cm = tempView("one\ntwo", [ EditorView.decorations.of(Decoration.set(Decoration.line({attributes: {style: "direction: rtl"}}).range(4))), EditorView.perLineTextDirection.of(true) ]) cm.measure() ist(cm.textDirectionAt(1), Direction.LTR) ist(cm.textDirectionAt(5), Direction.RTL) cm.dispatch({effects: StateEffect.appendConfig.of(EditorView.theme({".cm-content": {direction: "rtl"}}))}) cm.measure() ist(cm.textDirectionAt(1), Direction.RTL) }) }) view-6.26.3/test/webtest-domchange.ts000066400000000000000000000175221460616253600175310ustar00rootroot00000000000000import {tempView} from "./tempview.js" import {EditorState, StateField} from "@codemirror/state" import {Decoration, DecorationSet, EditorView, WidgetType} from "@codemirror/view" import ist from "ist" function flush(cm: EditorView) { cm.observer.flush() } describe("DOM changes", () => { it("notices text changes", () => { let cm = tempView("foo\nbar") cm.domAtPos(1).node.nodeValue = "froo" flush(cm) ist(cm.state.doc.toString(), "froo\nbar") }) it("handles browser enter behavior", () => { let cm = tempView("foo\nbar"), line0 = cm.contentDOM.firstChild! line0.appendChild(document.createElement("br")) line0.appendChild(document.createElement("br")) flush(cm) ist(cm.state.doc.toString(), "foo\n\nbar") }) it("supports deleting lines", () => { let cm = tempView("1\n2\n3\n4\n5\n6") for (let i = 0, lineDOM = cm.contentDOM; i < 4; i++) lineDOM.childNodes[1].remove() flush(cm) ist(cm.state.doc.toString(), "1\n6") }) it("can deal with large insertions", () => { let cm = tempView("okay") let node = document.createElement("div") node.textContent = "ayayayayayay" for (let i = 0, lineDOM = cm.domAtPos(0).node.parentNode!; i < 100; i++) lineDOM.appendChild(node.cloneNode(true)) flush(cm) ist(cm.state.doc.toString(), "okay" + "\nayayayayayay".repeat(100)) }) it("properly handles selection for ambiguous backspace", () => { let cm = tempView("foo") cm.dispatch({selection: {anchor: 2}}) cm.domAtPos(1).node.nodeValue = "fo" cm.inputState.lastKeyCode = 8 cm.inputState.lastKeyTime = Date.now() flush(cm) ist(cm.state.selection.main.anchor, 1) }) it("notices text changes at the end of a long document", () => { let cm = tempView("foo\nbar\n".repeat(15)) cm.domAtPos(8*15).node.textContent = "a" flush(cm) ist(cm.state.doc.toString(), "foo\nbar\n".repeat(15) + "a") }) it("handles replacing a selection with a prefix of itself", () => { let cm = tempView("foo\nbar") cm.dispatch({selection: {anchor: 0, head: 7}}) cm.contentDOM.textContent = "f" flush(cm) ist(cm.state.doc.toString(), "f") }) it("handles replacing a selection with a suffix of itself", () => { let cm = tempView("foo\nbar") cm.dispatch({selection: {anchor: 0, head: 7}}) cm.contentDOM.textContent = "r" flush(cm) ist(cm.state.doc.toString(), "r") }) it("handles replacing a selection with a prefix of itself and something else", () => { let cm = tempView("foo\nbar") cm.dispatch({selection: {anchor: 0, head: 7}}) cm.contentDOM.textContent = "fa" flush(cm) ist(cm.state.doc.toString(), "fa") }) it("handles replacing a selection with a suffix of itself and something else", () => { let cm = tempView("foo\nbar") cm.dispatch({selection: {anchor: 0, head: 7}}) cm.contentDOM.textContent = "br" flush(cm) ist(cm.state.doc.toString(), "br") }) it("handles replacing a selection with new content that shares a prefix and a suffix", () => { let cm = tempView("foo\nbar") cm.dispatch({selection: {anchor: 1, head: 6}}) cm.contentDOM.textContent = "fo--ar" flush(cm) ist(cm.state.doc.toString(), "fo--ar") }) it("handles appending", () => { let cm = tempView("foo\nbar") cm.dispatch({selection: {anchor: 7}}) cm.contentDOM.appendChild(document.createElement("div")) flush(cm) ist(cm.state.doc.toString(), "foo\nbar\n") }) it("handles deleting the first line and the newline after it", () => { let cm = tempView("foo\nbar\n\nbaz") cm.contentDOM.innerHTML = "bar

baz
" flush(cm) ist(cm.state.doc.toString(), "bar\n\nbaz") }) it("handles deleting a line with an empty line after it", () => { let cm = tempView("foo\nbar\n\nbaz") cm.contentDOM.innerHTML = "
foo

baz
" flush(cm) ist(cm.state.doc.toString(), "foo\n\nbaz") }) it("doesn't drop collapsed text", () => { let field = StateField.define({ create() { return Decoration.set(Decoration.replace({}).range(1, 3)) }, update() { return Decoration.none }, provide: f => EditorView.decorations.from(f) }) let cm = tempView("abcd", [field]) cm.domAtPos(0).node.textContent = "x" flush(cm) ist(cm.state.doc.toString(), "xbcd") }) it("preserves text nodes when edited in the middle", () => { let cm = tempView("abcd"), text = cm.domAtPos(1).node text.textContent = "axxd" flush(cm) ist(cm.domAtPos(1).node, text) }) it("preserves text nodes when edited at the start", () => { let cm = tempView("abcd"), text = cm.domAtPos(1).node text.textContent = "xxcd" flush(cm) ist(cm.domAtPos(1).node, text) }) it("preserves text nodes when edited at the end", () => { let cm = tempView("abcd"), text = cm.domAtPos(1).node text.textContent = "abxx" flush(cm) ist(cm.domAtPos(1).node, text) }) it("doesn't insert newlines for block widgets", () => { class Widget extends WidgetType { toDOM() { return document.createElement("div") } } let field = StateField.define({ create() { return Decoration.set(Decoration.widget({widget: new Widget }).range(4)) }, update(v) { return v }, provide: f => EditorView.decorations.from(f) }) let cm = tempView("abcd", [field]) cm.contentDOM.firstChild!.appendChild(document.createTextNode("x")) flush(cm) ist(cm.state.doc.toString(), "abcdx") }) it("correctly handles changes ending on a widget", () => { let widget = new class extends WidgetType { toDOM() { return document.createElement("strong") } } let field = StateField.define({ create() { return Decoration.set([Decoration.widget({widget}).range(2), Decoration.widget({widget}).range(7)]) }, update(v, tr) { return v.map(tr.changes) }, provide: f => EditorView.decorations.from(f) }) let cm = tempView("one two thr", [field]) let wDOM = cm.contentDOM.querySelectorAll("strong")[1] cm.domAtPos(6).node.nodeValue = "e" wDOM.remove() flush(cm) ist(cm.state.doc.toString(), "one thr") }) it("calls input handlers", () => { let cm = tempView("abc", [EditorView.inputHandler.of((_v, from, to, insert) => { cm.dispatch({changes: {from, to, insert: insert.toUpperCase()}}) return true })]) cm.contentDOM.firstChild!.appendChild(document.createTextNode("d")) flush(cm) ist(cm.state.doc.toString(), "abcD") }) it("ignores dom-changes in read-only mode", () => { let cm = tempView("abc", [EditorState.readOnly.of(true)]) cm.domAtPos(0).node.nodeValue = "abx" flush(cm) ist(cm.state.doc.toString(), "abc") ist(cm.contentDOM.textContent, "abc") }) it("can handle crlf insertion", () => { let cm = tempView("abc") let text = cm.domAtPos(1).node text.nodeValue = "ab\r\nc" getSelection()!.collapse(text, 4) flush(cm) ist(cm.state.doc.toString(), "ab\nc") ist(cm.state.selection.main.head, 3) }) it("works when line breaks are multiple characters", () => { let cm = tempView("abc", [EditorState.lineSeparator.of("\r\n")]) let text = cm.domAtPos(1).node text.nodeValue = "ab\r\nc" getSelection()!.collapse(text, 4) flush(cm) ist(cm.state.sliceDoc(), "ab\r\nc") ist(cm.state.selection.main.head, 3) }) it("doesn't insert a newline after a block widget", () => { let widget = new class extends WidgetType { toDOM() { return document.createElement("div") } } let cm = tempView("\n\n", [EditorView.decorations.of(Decoration.set(Decoration.widget({widget, block: true}).range(0)))]) let newLine = document.createElement("div") newLine.innerHTML = "
" cm.contentDOM.insertBefore(newLine, cm.contentDOM.childNodes[1]) flush(cm) ist(cm.state.sliceDoc(), "\n\n\n") }) }) view-6.26.3/test/webtest-draw-decoration.ts000066400000000000000000000723211460616253600206640ustar00rootroot00000000000000import {EditorView, Decoration, DecorationSet, WidgetType, ViewPlugin} from "@codemirror/view" import {tempView, requireFocus} from "./tempview.js" import {EditorSelection, StateEffect, StateField, Range} from "@codemirror/state" import ist from "ist" const filterDeco = StateEffect.define<(from: number, to: number, spec: any) => boolean>() const addDeco = StateEffect.define[]>() function text(node: Node) { return (node.textContent || "").replace(/\u200b/g, "") } function decos(startState: DecorationSet = Decoration.none) { let field = StateField.define({ create() { return startState }, update(value, tr) { value = value.map(tr.changes) for (let effect of tr.effects) { if (effect.is(addDeco)) value = value.update({add: effect.value}) else if (effect.is(filterDeco)) value = value.update({filter: effect.value}) } return value }, provide: f => EditorView.decorations.from(f) }) return [field] } function d(from: number, to: any, spec: any = null) { return Decoration.mark(typeof spec == "string" ? {attributes: {[spec]: "y"}} : spec).range(from, to) } function w(pos: number, widget: WidgetType, side: number = 0) { return Decoration.widget({widget, side}).range(pos) } function l(pos: number, attrs: any) { return Decoration.line(typeof attrs == "string" ? {attributes: {class: attrs}} : attrs).range(pos) } function decoEditor(doc: string, decorations: any = []) { return tempView(doc, decos(Decoration.set(decorations, true))) } describe("EditorView decoration", () => { it("renders tag names", () => { let cm = decoEditor("one\ntwo", d(2, 5, {tagName: "em"})) ist(cm.contentDOM.innerHTML.replace(/<\/?div.*?>/g, "|"), "|one||two|") }) it("renders attributes", () => { let cm = decoEditor("foo bar", [d(0, 3, {attributes: {title: "t"}}), d(4, 7, {attributes: {lang: "nl"}})]) ist(cm.contentDOM.querySelectorAll("[title]").length, 1) ist((cm.contentDOM.querySelector("[title]") as any).title, "t") ist(cm.contentDOM.querySelectorAll("[lang]").length, 1) }) it("updates for added decorations", () => { let cm = decoEditor("hello\ngoodbye") cm.dispatch({effects: addDeco.of([d(2, 8, {class: "c"})])}) let spans = cm.contentDOM.querySelectorAll(".c") ist(spans.length, 2) ist(text(spans[0]), "llo") ist(text(spans[0].previousSibling!), "he") ist(text(spans[1]), "go") ist(text(spans[1].nextSibling!), "odbye") }) it("updates for removed decorations", () => { let cm = decoEditor("one\ntwo\nthree", [d(1, 12, {class: "x"}), d(4, 7, {tagName: "strong"})]) cm.dispatch({effects: filterDeco.of((from: number) => from == 4)}) ist(cm.contentDOM.querySelectorAll(".x").length, 0) ist(cm.contentDOM.querySelectorAll("strong").length, 1) }) it("doesn't update DOM that doesn't need to change", () => { let cm = decoEditor("one\ntwo", [d(0, 3, {tagName: "em"})]) let secondLine = cm.contentDOM.lastChild!, secondLineText = secondLine.firstChild cm.dispatch({effects: filterDeco.of(() => false)}) ist(cm.contentDOM.lastChild, secondLine) ist(secondLine.firstChild, secondLineText) }) it("nests decoration elements", () => { let cm = tempView("abcdef", [decos(Decoration.set([d(2, 6, {class: "b"})])), decos(Decoration.set([d(0, 4, {class: "a"})]))]) let a = cm.contentDOM.querySelectorAll(".a"), b = cm.contentDOM.querySelectorAll(".b") ist(a.length, 1) ist(b.length, 2) ist(text(a[0]), "abcd") ist(text(b[0]), "cd") ist(b[0].parentNode, a[0]) ist(text(b[1]), "ef") }) it("drops entirely deleted decorations", () => { let cm = decoEditor("abc", [d(1, 2, {inclusiveStart: true, inclusiveEnd: true, tagName: "strong"})]) cm.dispatch({changes: {from: 0, to: 3, insert: "a"}}) ist(cm.contentDOM.querySelector("strong"), null) }) it("doesn't merge separate decorations", () => { let cm = decoEditor("abcd", [d(0, 2, {class: "a"}), d(2, 4, {class: "a"})]) ist(cm.contentDOM.querySelectorAll(".a").length, 2) cm.dispatch({changes: {from: 1, to: 3}}) ist(cm.contentDOM.querySelectorAll(".a").length, 2) }) it("merges joined decorations", () => { let cm = decoEditor("ab cd", [d(0, 2, {class: "a"}), d(3, 5, {class: "a"})]) cm.dispatch({changes: {from: 2, to: 3, insert: "x"}, effects: [filterDeco.of(() => false), addDeco.of([d(0, 5, {class: "a"})])]}) ist(cm.contentDOM.querySelectorAll(".a").length, 1) }) it("merges stacked decorations", () => { let cm = tempView("one", [ decos(Decoration.set([], true)), EditorView.decorations.of(Decoration.set(d(0, 3, {class: "a"}))) ]) cm.dispatch({effects: [addDeco.of([d(1, 2, {class: "b"})])]}) ist(cm.contentDOM.querySelectorAll(".a").length, 1) }) it("keeps decorations together when deleting inside of them", () => { let cm = decoEditor("one\ntwo", [d(1, 6, {class: "a"})]) ist(cm.contentDOM.querySelectorAll(".a").length, 2) cm.dispatch({changes: {from: 2, to: 5}}) ist(cm.contentDOM.querySelectorAll(".a").length, 1) }) it("does merge recreated decorations", () => { let cm = decoEditor("abcde", [d(1, 4, {class: "c"})]) cm.dispatch({changes: {from: 2, to: 5, insert: "CDE"}, effects: [filterDeco.of(() => false), addDeco.of([d(1, 4, {class: "c"})])]}) let a = cm.contentDOM.querySelectorAll(".c") ist(a.length, 1) ist(text(a[0]), "bCD") }) it("breaks high-precedence ranges for low-precedence wrappers", () => { let cm = tempView("abc", [decos(Decoration.set([d(1, 3, {class: "b"})])), decos(Decoration.set([d(0, 2, {class: "a"})]))]) let a = cm.contentDOM.querySelectorAll(".a") let b = cm.contentDOM.querySelectorAll(".b") ist(a.length, 1) ist(b.length, 2) ist(b[0].parentNode, a[0]) }) it("draws outer decorations around others", () => { let cm = tempView("abcde", [ decos(Decoration.set([d(1, 2, {class: "a"}), d(3, 4, {class: "a"})])), EditorView.outerDecorations.of(Decoration.set(Decoration.mark({tagName: "strong"}).range(1, 4))), EditorView.outerDecorations.of(Decoration.set(Decoration.mark({tagName: "var"}).range(0, 5))), EditorView.outerDecorations.of(Decoration.set(Decoration.mark({tagName: "em"}).range(2, 3))) ]) ist((cm.contentDOM.firstChild as HTMLElement).innerHTML, `abcde`) }) it("properly updates the viewport gap when changes fall inside it", () => { let doc = "a\n".repeat(500) let cm = decoEditor(doc, [d(600, 601, "x")]) cm.dom.style.height = "100px" cm.scrollDOM.style.overflow = "auto" cm.scrollDOM.scrollTop = 0 cm.measure() cm.dispatch({ changes: {from: 500, insert: " "}, selection: EditorSelection.single(0, doc.length + 2) }) }) class WordWidget extends WidgetType { constructor(readonly word: string) { super() } eq(other: WordWidget) { return this.word.toLowerCase() == other.word.toLowerCase() } toDOM() { let dom = document.createElement("strong") dom.textContent = this.word return dom } } describe("widget", () => { class OtherWidget extends WidgetType { toDOM() { return document.createElement("img") } } it("draws widgets", () => { let cm = decoEditor("hello", [w(4, new WordWidget("hi"))]) let elt = cm.contentDOM.querySelector("strong")! ist(elt) ist(text(elt), "hi") ist(elt.contentEditable, "false") ist(text(cm.contentDOM), "hellhio") }) it("supports editing around widgets", () => { let cm = decoEditor("hello", [w(4, new WordWidget("hi"))]) cm.dispatch({changes: {from: 3, to: 4}}) cm.dispatch({changes: {from: 3, to: 4}}) ist(cm.contentDOM.querySelector("strong")) }) it("compares widgets with their eq method", () => { let cm = decoEditor("hello", [w(4, new WordWidget("hi"))]) let elt = cm.contentDOM.querySelector("strong") cm.dispatch({ effects: [filterDeco.of(() => false), addDeco.of([w(4, new WordWidget("HI"))])] }) ist(elt, cm.contentDOM.querySelector("strong")) }) it("notices replaced replacement decorations", () => { let cm = decoEditor("abc", [Decoration.replace({widget: new WordWidget("X")}).range(1, 2)]) cm.dispatch({effects: [filterDeco.of(() => false), addDeco.of([Decoration.replace({widget: new WordWidget("Y")}).range(1, 2)])]}) ist(text(cm.contentDOM), "aYc") }) it("allows replacements to shadow inner replacements", () => { let cm = decoEditor("one\ntwo\nthree\nfour", [ Decoration.replace({widget: new WordWidget("INNER")}).range(5, 12) ]) cm.dispatch({effects: addDeco.of([Decoration.replace({widget: new WordWidget("OUTER")}).range(1, 17)])}) ist(text(cm.contentDOM), "oOUTERr") }) it("doesn't consider different widgets types equivalent", () => { let cm = decoEditor("hello", [w(4, new WordWidget("hi"))]) let elt = cm.contentDOM.querySelector("strong") cm.dispatch({effects: [ filterDeco.of(() => false), addDeco.of([w(4, new OtherWidget)]) ]}) ist(elt, cm.contentDOM.querySelector("strong"), "!=") }) it("orders widgets by side", () => { let cm = decoEditor("hello", [w(4, new WordWidget("A"), -1), w(4, new WordWidget("B")), w(4, new WordWidget("C"), 10)]) let widgets = cm.contentDOM.querySelectorAll("strong") ist(widgets.length, 3) ist(text(widgets[0]), "A") ist(text(widgets[1]), "B") ist(text(widgets[2]), "C") }) it("places the cursor based on side", () => { let cm = requireFocus( decoEditor("abc", [w(2, new WordWidget("A"), -1), w(2, new WordWidget("B"), 1)])) cm.dispatch({selection: {anchor: 2}}) let selRange = document.getSelection()!.getRangeAt(0) let widgets = cm.contentDOM.querySelectorAll("strong") ist(text(widgets[0]), "A") ist(text(widgets[1]), "B") ist(selRange.comparePoint(widgets[0], 0), -1) ist(selRange.comparePoint(widgets[1], 0), 1) }) it("preserves widgets alongside edits regardless of side", () => { let cm = decoEditor("abc", [w(1, new WordWidget("x"), -1), w(1, new WordWidget("y"), 1), w(2, new WordWidget("z"), -1), w(2, new WordWidget("q"), 1)]) cm.dispatch({changes: {from: 1, to: 2, insert: "B"}}) ist(text(cm.contentDOM), "axyBzqc") }) it("can update widgets in an empty document", () => { let cm = decoEditor("", [w(0, new WordWidget("A"))]) cm.dispatch({effects: addDeco.of([w(0, new WordWidget("B"))])}) ist(cm.contentDOM.querySelectorAll("strong").length, 2) }) it("doesn't duplicate widgets on line splitting", () => { let cm = decoEditor("a", [w(1, new WordWidget("W"), 1)]) cm.dispatch({changes: {from: 1, insert: "\n"}}) ist(cm.contentDOM.querySelectorAll("strong").length, 1) }) it("can remove widgets at the end of a line", () => { // Issue #139 let cm = decoEditor("one\ntwo", [w(3, new WordWidget("A"))]) cm.dispatch({effects: [filterDeco.of(() => false), addDeco.of([w(5, new WordWidget("B"))])]}) ist(cm.contentDOM.querySelectorAll("strong").length, 1) }) it("can wrap widgets in marks", () => { let cm = tempView("abcd", [decos(Decoration.set([d(1, 3, {class: "b"})])), decos(Decoration.set([w(2, new WordWidget("hi"))])), decos(Decoration.set([d(0, 4, {class: "a"})]))]) let a = cm.contentDOM.querySelectorAll(".a") let b = cm.contentDOM.querySelectorAll(".b") let wordElt = cm.contentDOM.querySelector("strong") ist(a.length, 1) ist(b.length, 2) ist(wordElt) ist(wordElt!.parentNode, a[0]) ist(b[0].parentNode, a[0]) ist(text(b[0]), "b") ist(text(b[1]), "c") cm.dispatch({effects: [filterDeco.of(from => from != 2)]}) ist(cm.contentDOM.querySelectorAll(".b").length, 1) }) it("includes negative-side widgets in marks that end at their position", () => { let cm = tempView("123", [decos(Decoration.set([w(2, new WordWidget("x"), -1)])), decos(Decoration.set([d(0, 2, {tagName: "em", inclusive: true})]))]) ist(cm.contentDOM.querySelector("em")!.textContent, "12x") }) it("includes positive-side widgets in marks that start at their position", () => { let cm = tempView("123", [decos(Decoration.set([w(1, new WordWidget("x"), 1)])), decos(Decoration.set([d(1, 3, {tagName: "em", inclusive: true})]))]) ist(cm.contentDOM.querySelector("em")!.textContent, "x23") }) it("wraps widgets even when the mark starts at the same offset", () => { let repl = Decoration.replace({widget: new WordWidget("X"), inclusive: false}) let cm = tempView("abcd", [decos(Decoration.set([repl.range(1, 3)])), decos(Decoration.set([d(1, 3, {class: "a", inclusive: true})]))]) let a = cm.contentDOM.querySelectorAll(".a") let w = cm.contentDOM.querySelectorAll("strong") ist(a.length, 1) ist(w.length, 1) ist(w[0].parentNode, a[0]) }) it("merges text around a removed widget", () => { let cm = tempView("1234", [decos(Decoration.set([w(2, new WordWidget("x"))]))]) cm.dispatch({effects: filterDeco.of(() => false)}) ist(cm.domAtPos(2).node.nodeValue, "1234") }) it("draws buffers around widgets", () => { let cm = tempView("1234", [decos(Decoration.set([w(1, new WordWidget("x"), 1), w(3, new WordWidget("y"), -1)]))]) ist(cm.contentDOM.innerHTML.replace(//g, "#").replace(/<\/?\w+[^>]*>/g, ""), "1#x23y#4") }) it("doesn't draw unnecessary buffers between adjacent widgets", () => { let cm = tempView("1234", [decos(Decoration.set([w(1, new WordWidget("x"), 1), w(1, new WordWidget("x"), 1), w(3, new WordWidget("x"), -1), w(3, new WordWidget("x"), -1)]))]) ist(cm.contentDOM.innerHTML.replace(//g, "#").replace(/<\/?\w+[^>]*>/g, ""), "1#xx23xx#4") }) it("doesn't wrap buffers at the start of a mark in the mark", () => { let cm = tempView("abc", [decos(Decoration.set([w(1, new WordWidget("x")), d(1, 2, "m")]))]) ist(cm.contentDOM.querySelectorAll("[m]").length, 1) }) it("puts a buffer in front of widgets spanned by marks", () => { let cm = tempView("a\n\nc", [ decos(Decoration.set([d(0, 4, "m")])), decos(Decoration.set([w(2, new WordWidget("Q"), 1)])), ]) ist(cm.contentDOM.querySelectorAll("img").length, 1) }) it("calls the destroy method on destroyed widgets", () => { let destroyed: string[] = [] class W extends WordWidget { destroy() { destroyed.push(this.word) } } let w1 = new W("A"), w2 = new W("B") let cm = tempView("abcde", [decos(Decoration.set([w(1, w1), w(2, w2), w(4, w2)]))]) cm.dispatch({changes: {from: 0, to: 3}}) ist(destroyed.sort().join(), "A,B") cm.dispatch({changes: {from: 0, to: 2}}) ist(destroyed.sort().join(), "A,B,B") }) it("calls the destroy method widgets when the editor is destroyed", () => { let destroyed: string[] = [] class W extends WordWidget { destroy() { destroyed.push(this.word) } } let cm = tempView("abcde", [decos(Decoration.set([w(1, new W("A")), w(2, new W("B"))]))]) cm.destroy() ist(destroyed.sort().join(), "A,B") }) it("calls destroy on updated widgets", () => { let destroyed: string[] = [] class W extends WordWidget { destroy() { destroyed.push(this.word) } } let cm = tempView("abcde", [decos(Decoration.set([w(1, new W("A"))]))]) cm.dispatch({effects: [ filterDeco.of(() => false), addDeco.of([w(1, new W("B"))]) ]}) ist(destroyed.sort().join(), "A") }) it("can show inline and block widgets next to each other after a position", () => { let cm = tempView("xy", [decos(Decoration.set([ w(1, new WordWidget("A"), 1), Decoration.widget({widget: new BlockWidget("B"), block: true, side: 2, inlineOrder: true}).range(1), w(1, new WordWidget("C"), 3), ]))]) let [a, c] = Array.from(cm.contentDOM.querySelectorAll("strong")) let b = cm.contentDOM.querySelector("hr")! ist(a.parentNode, cm.contentDOM.firstChild) ist(c.parentNode, cm.contentDOM.lastChild) ist(b.previousSibling, a.parentNode) ist(b.nextSibling, c.parentNode) }) it("can show inline and block widgets next to each other before a position", () => { let cm = tempView("xy", [decos(Decoration.set([ w(1, new WordWidget("A"), -3), Decoration.widget({widget: new BlockWidget("B"), block: true, side: -2, inlineOrder: true}).range(1), w(1, new WordWidget("C"), -2), ]))]) let [a, c] = Array.from(cm.contentDOM.querySelectorAll("strong")) let b = cm.contentDOM.querySelector("hr")! ist(a.parentNode, cm.contentDOM.firstChild) ist(c.parentNode, cm.contentDOM.lastChild) ist(b.previousSibling, a.parentNode) ist(b.nextSibling, c.parentNode) }) }) function r(from: number, to: number, spec: any = {}) { return Decoration.replace(spec).range(from, to) } describe("replaced", () => { it("omits replaced content", () => { let cm = decoEditor("foobar", [r(1, 4)]) ist(text(cm.contentDOM), "far") }) it("can replace across lines", () => { let cm = decoEditor("foo\nbar\nbaz\nbug", [r(1, 14)]) ist(cm.contentDOM.childNodes.length, 1) ist(text(cm.contentDOM.firstChild!), "fg") }) it("draws replacement widgets", () => { let cm = decoEditor("foo\nbar\nbaz", [r(6, 9, {widget: new WordWidget("X")})]) ist(text(cm.contentDOM), "foobaXaz") }) it("can handle multiple overlapping replaced ranges", () => { let cm = decoEditor("foo\nbar\nbaz\nbug", [r(1, 6), r(6, 9), r(8, 14)]) ist(cm.contentDOM.childNodes.length, 1) ist(text(cm.contentDOM.firstChild!), "fg") }) it("allows splitting a replaced range", () => { let cm = decoEditor("1234567890", [r(1, 9)]) cm.dispatch({ changes: {from: 2, to: 8, insert: "abcdef"}, effects: [filterDeco.of(_ => false), addDeco.of([r(1, 3), r(7, 9)])] }) ist(text(cm.contentDOM.firstChild!), "1bcde0") }) it("allows replacing a single replaced range with two adjacent ones", () => { let cm = decoEditor("1234567890", [r(1, 9)]) cm.dispatch({ changes: {from: 2, to: 8, insert: "cdefgh"}, effects: [filterDeco.of(_ => false), addDeco.of([r(1, 5), r(5, 9)])] }) ist(text(cm.contentDOM.firstChild!), "10") ist((cm.contentDOM.firstChild as HTMLElement).querySelectorAll("span").length, 2) }) it("can handle changes inside replaced content", () => { let cm = decoEditor("abcdefghij", [r(2, 8)]) cm.dispatch({changes: {from: 4, to: 6, insert: "n"}}) ist(text(cm.contentDOM), "abij") }) it("preserves selection endpoints inside replaced ranges", () => { let cm = requireFocus(decoEditor("abcdefgh", [r(0, 4)])) cm.dispatch({selection: {anchor: 2, head: 6}}) let sel = document.getSelection()!, range = document.createRange() range.setEnd(sel.focusNode!, sel.focusOffset + 1) range.setStart(sel.anchorNode!, sel.anchorOffset) sel.removeAllRanges() sel.addRange(range) cm.observer.flush() let {anchor, head} = cm.state.selection.main ist(head, 7) ist(anchor, 2) }) it("draws buffers around replacements", () => { let cm = tempView("12345", [decos(Decoration.set([r(0, 1, {widget: new WordWidget("a")}), r(2, 3, {widget: new WordWidget("b")}), r(4, 5, {widget: new WordWidget("c")})]))]) ist(cm.contentDOM.innerHTML.replace(//g, "#").replace(/<\/?\w+[^>]*>/g, ""), "#a#2#b#4#c#") }) it("properly handles marks growing to include replaced ranges", () => { let cm = tempView("1\n2\n3\n4", [ EditorView.decorations.of(Decoration.set(r(4, 5, {widget: new WordWidget("×")}))), decos(Decoration.none), ]) cm.dispatch({effects: addDeco.of([d(4, 6, {class: "a"})])}) cm.dispatch({effects: [filterDeco.of(() => false), addDeco.of([d(2, 6, {class: "a"})])]}) ist(cm.contentDOM.querySelectorAll("strong").length, 1) }) it("covers block ranges at the end of a replaced range", () => { let cm = tempView("1\n2\n3\n4", [ EditorView.decorations.of(Decoration.set([r(4, 5, {widget: new WordWidget("B"), block: true})])), EditorView.decorations.of(Decoration.set([r(1, 5, {widget: new WordWidget("F")})])), ]) ist(cm.contentDOM.querySelectorAll("strong").length, 1) }) it("raises errors for replacing decorations from plugins if they cross lines", () => { ist.throws(() => { tempView("one\ntwo", [ViewPlugin.fromClass(class { update!: () => void deco = Decoration.set(Decoration.replace({widget: new WordWidget("ay")}).range(2, 5)) }, { decorations: o => o.deco })]) }, "Decorations that replace line breaks may not be specified via plugins") }) }) describe("line attributes", () => { function classes(cm: EditorView, ...lines: string[]) { for (let i = 0; i < lines.length; i++) { let className = (cm.contentDOM.childNodes[i] as HTMLElement).className.split(" ") .filter(c => c != "cm-line" && !/ͼ/.test(c)).sort().join(" ") ist(className, lines[i]) } } it("adds line attributes", () => { let cm = decoEditor("abc\ndef\nghi", [l(0, "a"), l(0, "b"), l(1, "c"), l(8, "d")]) classes(cm, "a b", "", "d") }) it("updates when line attributes are added", () => { let cm = decoEditor("foo\nbar", [l(0, "a")]) cm.dispatch({effects: addDeco.of([l(0, "b"), l(4, "c")])}) classes(cm, "a b", "c") }) it("updates when line attributes are removed", () => { let ds = [l(0, "a"), l(0, "b"), l(4, "c")] let cm = decoEditor("foo\nbar", ds) cm.dispatch({effects: filterDeco.of( (_f: number, _t: number, deco: Decoration) => !ds.slice(1).some(r => r.value == deco))}) classes(cm, "a", "") }) it("handles line joining properly", () => { let cm = decoEditor("x\ny\nz", [l(0, "a"), l(2, "b"), l(4, "c")]) cm.dispatch({changes: {from: 1, to: 4}}) classes(cm, "a") }) it("handles line splitting properly", () => { let cm = decoEditor("abc", [l(0, "a")]) cm.dispatch({changes: {from: 1, to: 2, insert: "\n"}}) classes(cm, "a", "") }) it("can handle insertion", () => { let cm = decoEditor("x\ny\nz", [l(2, "a"), l(4, "b")]) cm.dispatch({changes: {from: 2, insert: "hi"}}) classes(cm, "", "a", "b") }) }) class BlockWidget extends WidgetType { constructor(readonly name: string) { super() } eq(other: BlockWidget) { return this.name == other.name } toDOM() { let elt = document.createElement("hr") elt.setAttribute("data-name", this.name) return elt } } function bw(pos: number, side = -1, name = "n") { return Decoration.widget({widget: new BlockWidget(name), side, block: true}).range(pos) } function br(from: number, to: number, name = "r", inclusive?: boolean) { return Decoration.replace({widget: new BlockWidget(name), inclusive, block: true}).range(from, to) } function widgets(cm: EditorView, ...groups: string[][]) { let found: string[][] = [[]] for (let n: Node | null = cm.contentDOM.firstChild; n; n = n.nextSibling) { if ((n as HTMLElement).nodeName == "HR") found[found.length - 1].push((n as HTMLElement).getAttribute("data-name")!) else found.push([]) } ist(JSON.stringify(found), JSON.stringify(groups)) } describe("block widgets", () => { it("draws block widgets in the right place", () => { let cm = decoEditor("foo\nbar", [bw(0, -1, "A"), bw(3, 1, "B"), bw(3, 2, "C"), bw(4, -2, "D"), bw(4, -1, "E"), bw(7, 1, "F")]) widgets(cm, ["A"], ["B", "C", "D", "E"], ["F"]) }) it("adds widgets when they appear", () => { let cm = decoEditor("foo\nbar", [bw(7, 1, "Y")]) cm.dispatch({effects: addDeco.of([bw(0, -1, "X"), bw(7, 2, "Z")])}) widgets(cm, ["X"], [], ["Y", "Z"]) }) it("removes widgets when they vanish", () => { let cm = decoEditor("foo\nbar", [bw(0, -1, "A"), bw(3, 1, "B"), bw(4, -1, "C"), bw(7, 1, "D")]) widgets(cm, ["A"], ["B", "C"], ["D"]) cm.dispatch({effects: filterDeco.of((_f: number, _t: number, deco: any) => deco.spec.side < 0)}) widgets(cm, ["A"], ["C"], []) }) it("draws block ranges", () => { let cm = decoEditor("one\ntwo\nthr\nfou", [br(4, 11, "A")]) widgets(cm, [], ["A"], []) }) it("can add widgets at the end and start of the doc", () => { let cm = decoEditor("one\ntwo") cm.dispatch({effects: addDeco.of([bw(0, -1, "X"), bw(7, 1, "Y")])}) widgets(cm, ["X"], [], ["Y"]) }) it("can add widgets around inner lines", () => { let cm = decoEditor("one\ntwo") cm.dispatch({effects: addDeco.of([bw(3, 1, "X"), bw(4, -1, "Y")])}) widgets(cm, [], ["X", "Y"], []) }) it("can replace an empty line with a range", () => { let cm = decoEditor("one\n\ntwo", [br(4, 4, "A")]) widgets(cm, [], ["A"], []) }) it("can put a block range in the middle of a line", () => { let cm = decoEditor("hello", [br(2, 3, "X")]) widgets(cm, [], ["X"], []) cm.dispatch({changes: {from: 1, to: 2, insert: "u"}, effects: addDeco.of([br(2, 3, "X")])}) widgets(cm, [], ["X"], []) cm.dispatch({changes: {from: 3, to: 4, insert: "i"}, effects: addDeco.of([br(2, 3, "X")])}) widgets(cm, [], ["X"], []) }) it("can draw a block range that partially overlaps with a collapsed range", () => { let cm = decoEditor("hello", [Decoration.replace({widget: new WordWidget("X")}).range(0, 3), br(1, 4, "Y")]) widgets(cm, [], ["Y"], []) ist(cm.contentDOM.querySelector("strong")) }) it("doesn't redraw unchanged widgets", () => { let cm = decoEditor("foo\nbar", [bw(0, -1, "A"), bw(7, 1, "B")]) let ws = cm.contentDOM.querySelectorAll("hr") cm.dispatch({effects: [ filterDeco.of((_f: number, _t: number, deco: any) => deco.spec.side < 0), addDeco.of([bw(7, 1, "B")]) ]}) widgets(cm, ["A"], [], ["B"]) let newWs = cm.contentDOM.querySelectorAll("hr") ist(newWs[0], ws[0]) ist(newWs[1], ws[1]) }) it("does redraw changed widgets", () => { let cm = decoEditor("foo\nbar", [bw(0, -1, "A"), bw(7, 1, "B")]) cm.dispatch({effects: [ filterDeco.of((_f: number, _t: number, deco: any) => deco.spec.side < 0), addDeco.of([bw(7, 1, "C")]) ]}) widgets(cm, ["A"], [], ["C"]) }) it("allows splitting a block widget", () => { let cm = decoEditor("1234567890", [br(1, 9, "X")]) cm.dispatch({ changes: {from: 2, to: 8, insert: "abcdef"}, effects: [filterDeco.of(_ => false), addDeco.of([br(1, 3, "X"), br(7, 9, "X")])] }) widgets(cm, [], ["X"], ["X"], []) }) it("block replacements cover inline widgets but not block widgets on their sides", () => { let cm = decoEditor("1\n2\n3", [ br(2, 3, "X"), w(2, new WordWidget("I1"), -1), w(3, new WordWidget("I1"), 1), bw(2, -1, "B1"), bw(3, 1, "B2") ]) ist(!cm.contentDOM.querySelector("strong")) widgets(cm, [], ["B1", "X", "B2"], []) }) it("block replacements cover inline replacements at their sides", () => { let cm = decoEditor("1\n234\n5", [ br(2, 5, "X"), r(2, 3, {widget: new WordWidget("I1"), inclusive: true}), r(4, 5, {widget: new WordWidget("I1"), inclusive: true}), ]) ist(!cm.contentDOM.querySelector("strong")) }) it("doesn't draw replaced lines even when decorated", () => { let cm = decoEditor("1\n234\n5", [ br(2, 5, "X"), l(2, {class: "line"}) ]) ist(!cm.contentDOM.querySelector(".line")) }) it("draws lines around non-inclusive block widgets", () => { let cm = decoEditor("1\n23\n4", [ br(0, 1, "X", false), br(2, 4, "Y", false), br(5, 6, "Z", false) ]) ist(cm.contentDOM.querySelectorAll(".cm-line").length, 6) }) it("raises an error when providing block widgets from plugins", () => { ist.throws(() => { tempView("abc", [ViewPlugin.fromClass(class { update!: () => void deco = Decoration.set(Decoration.replace({widget: new BlockWidget("oh"), block: true}).range(1, 2)) }, { decorations: o => o.deco })]) }, "Block decorations may not be specified via plugins") }) }) }) view-6.26.3/test/webtest-draw.ts000066400000000000000000000316001460616253600165320ustar00rootroot00000000000000import {tempView} from "./tempview.js" import {EditorSelection, Prec, StateField} from "@codemirror/state" import {EditorView, ViewPlugin, Decoration, DecorationSet, WidgetType} from "@codemirror/view" import ist from "ist" function domText(view: EditorView) { let text = "", eol = false function scan(node: Node) { if (node.nodeType == 1) { if (node.nodeName == "BR" || (node as HTMLElement).contentEditable == "false" || (node as HTMLElement).className == "cm-gap") return if (eol) { text += "\n"; eol = false } for (let ch = node.firstChild as (Node | null); ch; ch = ch.nextSibling) scan(ch) eol = true } else if (node.nodeType == 3) { text += node.nodeValue } } scan(view.contentDOM) return text } function scroll(height: number) { return [ EditorView.contentAttributes.of({style: "overflow: auto"}), EditorView.editorAttributes.of({style: `height: ${height}px`}), ViewPlugin.define(view => { view.scrollDOM.scrollTop = 0 return {} }) ] } describe("EditorView drawing", () => { it("follows updates to the document", () => { let cm = tempView("one\ntwo") ist(domText(cm), "one\ntwo") cm.dispatch({changes: {from: 1, to: 2, insert: "x"}}) ist(domText(cm), "oxe\ntwo") cm.dispatch({changes: {from: 2, to: 5, insert: "1\n2\n3"}}) ist(domText(cm), "ox1\n2\n3wo") cm.dispatch({changes: {from: 1, to: 8}}) ist(domText(cm), "oo") }) it("works in multiple lines", () => { let doc = "abcdefghijklmnopqrstuvwxyz\n".repeat(10) let cm = tempView("") cm.dispatch({changes: {from: 0, insert: doc}}) ist(domText(cm), doc) cm.dispatch({changes: {from: 0, insert: "/"}}) doc = "/" + doc ist(domText(cm), doc) cm.dispatch({changes: {from: 100, to: 104, insert: "$"}}) doc = doc.slice(0, 100) + "$" + doc.slice(104) ist(domText(cm), doc) cm.dispatch({changes: {from: 200, to: 268}}) doc = doc.slice(0, 200) ist(domText(cm), doc) }) it("can split a line", () => { let cm = tempView("abc\ndef\nghi") cm.dispatch({changes: {from: 4, insert: "xyz\nk"}}) ist(domText(cm), "abc\nxyz\nkdef\nghi") }) it("redraws lazily", () => { let cm = tempView("one\ntwo\nthree") let line0 = cm.contentDOM.firstChild!, line1 = line0.nextSibling!, line2 = line1.nextSibling! let text0 = line0.firstChild!, text2 = line2.firstChild! cm.dispatch({changes: {from: 5, insert: "x"}}) ist(text0.parentElement, line0) ist(cm.contentDOM.contains(line0)) ist(cm.contentDOM.contains(line1)) ist(text2.parentElement, line2) ist(cm.contentDOM.contains(line2)) }) it("notices the doc needs to be redrawn when only inserting empty lines", () => { let cm = tempView("") cm.dispatch({changes: {from: 0, insert: "\n\n\n"}}) ist(domText(cm), "\n\n\n") }) it("draws BR nodes on empty lines", () => { let cm = tempView("one\n\ntwo") let emptyLine = cm.domAtPos(4).node ist(emptyLine.childNodes.length, 1) ist(emptyLine.firstChild!.nodeName, "BR") cm.dispatch({changes: {from: 4, insert: "x"}}) ist(!Array.from(cm.domAtPos(4).node.childNodes).some(n => (n as any).nodeName == "BR")) }) it("only draws visible content", () => { let cm = tempView("a\n".repeat(500) + "b\n".repeat(500), [scroll(300)]) cm.scrollDOM.scrollTop = 3000 cm.measure() ist(cm.contentDOM.childNodes.length, 500, "<") ist(cm.contentDOM.scrollHeight, 10000, ">") ist(!cm.contentDOM.textContent!.match(/b/)) let gap = cm.contentDOM.lastChild cm.dispatch({changes: {from: 2000, insert: "\n\n"}}) ist(cm.contentDOM.lastChild, gap) // Make sure gap nodes are reused when resized cm.scrollDOM.scrollTop = cm.scrollDOM.scrollHeight / 2 cm.measure() ist(cm.contentDOM.textContent!.match(/b/)) }) it("scrolls the selection into view when asked", () => { let cm = tempView("\n".repeat(500), [scroll(150)]) cm.scrollDOM.scrollTop = 0 cm.measure() cm.dispatch({selection: {anchor: 250}, scrollIntoView: true}) let box = cm.scrollDOM.getBoundingClientRect(), pos = cm.coordsAtPos(250) ist(box.top, pos?.top, "<=") ist(box.bottom, pos?.bottom, ">=") }) it("can scroll ranges into view", () => { let cm = tempView("\n".repeat(500), [scroll(150)]) cm.scrollDOM.scrollTop = 0 cm.measure() cm.dispatch({effects: EditorView.scrollIntoView(250)}) let box = cm.scrollDOM.getBoundingClientRect(), pos = cm.coordsAtPos(250) ist(box.top, pos?.top, "<=") ist(box.bottom, pos?.bottom, ">=") cm.dispatch({effects: EditorView.scrollIntoView(EditorSelection.range(403, 400))}) let top = cm.coordsAtPos(400), bot = cm.coordsAtPos(403) ist(box.top, top?.top, "<=") ist(box.bottom, bot?.bottom, ">=") cm.dispatch({effects: EditorView.scrollIntoView(EditorSelection.range(300, 400))}) let pos400 = cm.coordsAtPos(400) ist(box.top, pos400?.top, "<=") ist(box.bottom, pos400?.bottom, ">=") cm.dispatch({effects: EditorView.scrollIntoView(EditorSelection.range(150, 100))}) let pos100 = cm.coordsAtPos(100) ist(box.top, pos100?.top, "<=") ist(box.bottom, pos100?.bottom, ">=") }) it("keeps a drawn area around selection ends", () => { let cm = tempView("\nsecond\n" + "x\n".repeat(500) + "last", [scroll(300)]) cm.dispatch({selection: EditorSelection.single(1, cm.state.doc.length)}) cm.focus() let text = cm.contentDOM.textContent! ist(text.length, 500, "<") ist(/second/.test(text)) ist(/last/.test(text)) }) it("can handle replace-all like events", () => { let content = "", chars = "abcdefghijklmn \n" for (let i = 0; i < 5000; i++) content += chars[Math.floor(Math.random() * chars.length)] let cm = tempView(content), changes = [] for (let i = Math.floor(content.length / 100); i >= 0; i--) { let from = Math.floor(Math.random() * (cm.state.doc.length - 10)), to = from + Math.floor(Math.random() * 10) changes.push({from, to, insert: "XYZ"}) } cm.dispatch({changes}) ist(domText(cm), cm.state.sliceDoc(cm.viewport.from, cm.viewport.to)) }) it("can replace across line boundaries", () => { let cm = tempView("ab\ncd\nef") cm.dispatch({changes: {from: 1, to: 4, insert: "XYZ"}}) ist(domText(cm), cm.state.doc.toString()) }) it("can handle deleting a line's content", () => { let cm = tempView("foo\nbaz") cm.dispatch({changes: {from: 4, to: 7}}) ist(domText(cm), "foo\n") }) it("can insert blank lines at the end of the document", () => { let cm = tempView("foo") cm.dispatch({changes: {from: 3, insert: "\n\nx"}}) ist(domText(cm), "foo\n\nx") }) it("can handle deleting the end of a line", () => { let cm = tempView("a\nbc\n") cm.dispatch({changes: {from: 3, to: 4}}) cm.dispatch({changes: {from: 3, insert: "d"}}) ist(domText(cm), "a\nbd\n") }) it("correctly handles very complicated transactions", () => { let doc = "foo\nbar\nbaz", chars = "abcdef \n" let cm = tempView(doc) for (let i = 0; i < 10; i++) { let changes = [], pos = Math.min(20, doc.length) for (let j = 0; j < 1; j++) { let choice = Math.random(), r = Math.random() if (choice < 0.15) { pos = Math.min(doc.length, Math.max(0, pos + 5 - Math.floor(r * 10))) } else if (choice < 0.5) { let from = Math.max(0, pos - Math.floor(r * 2)), to = Math.min(doc.length, pos + Math.floor(r * 4)) changes.push({from, to}) pos = from } else { let text = "" for (let k = Math.floor(r * 6); k >= 0; k--) text += chars[Math.floor(chars.length * Math.random())] changes.push({from: pos, insert: text}) } } cm.dispatch({changes}) doc = cm.state.doc.toString() ist(domText(cm), doc.slice(cm.viewport.from, cm.viewport.to)) } }) function later(t = 50) { return new Promise(resolve => setTimeout(resolve, t)) } it("notices it is added to the DOM even if initially detached", () => { if (!(window as any).IntersectionObserver) return // Only works with intersection observer support let cm = new EditorView({doc: "a\nb\nc\nd"}) let defaultHeight = cm.contentHeight ;(document.querySelector("#workspace") as HTMLElement).appendChild(cm.dom) return later().then(() => { ist(cm.contentHeight, defaultHeight, "!=") cm.destroy() }) }) it("hides parts of long lines that are horizontally out of view", () => { let cm = tempView("one\ntwo\n?" + "three ".repeat(3333) + "!\nfour") let {node} = cm.domAtPos(9) ist(node.nodeValue!.length, 2e4, "<") ist(node.nodeValue!.indexOf("!"), -1) ist(cm.scrollDOM.scrollWidth, cm.defaultCharacterWidth * 1.6e4, ">") cm.scrollDOM.scrollLeft = cm.scrollDOM.scrollWidth cm.measure() ;({node} = cm.domAtPos(20007)!) ist(node.nodeValue!.length, 2e4, "<") ist(node.nodeValue!.indexOf("!"), -1, ">") ist(cm.scrollDOM.scrollWidth, cm.defaultCharacterWidth * 1.6e4, ">") }) const bigText = Decoration.line({attributes: {style: "font-size: 300%"}}) it("stabilizes the scroll position in the middle", () => { let cm = tempView("\n".repeat(400), [scroll(100), EditorView.decorations.of(Decoration.set( Array.from(new Array(10), (_, i) => bigText.range(100 + i))))]) cm.scrollDOM.scrollTop = cm.lineBlockAt(300).top cm.measure() ist(Math.abs(cm.lineBlockAt(300).top - cm.scrollDOM.scrollTop), 2, "<") }) it("stabilizes the scroll position at the end", () => { let cm = tempView("\n".repeat(400), [scroll(100), EditorView.decorations.of(Decoration.set( Array.from(new Array(10), (_, i) => bigText.range(100 + i))))]) cm.scrollDOM.scrollTop = 1e9 cm.measure() ist(Math.abs(cm.scrollDOM.scrollHeight - cm.scrollDOM.clientHeight - cm.scrollDOM.scrollTop), 2, "<") }) it("doesn't overcompensate scroll position for poorly estimated height", () => { let deco = () => { let widget = new class extends WidgetType { get estimatedHeight() { return 1 } toDOM() { let elt = document.createElement("div") elt.style.height = "100px" return elt } } return Decoration.set(Decoration.widget({widget, block: true}).range(200)) } let cm = tempView("\n".repeat(400), [scroll(100), StateField.define({ create: deco, update: deco, provide: f => EditorView.decorations.from(f) })]) cm.dispatch({effects: EditorView.scrollIntoView(205, {y: "start"})}) cm.measure() let prev = cm.scrollDOM.scrollTop cm.dispatch({selection: {anchor: 205}}) cm.measure() ist(prev, cm.scrollDOM.scrollTop) }) it("hides parts of long lines that are vertically out of view", () => { let cm = tempView("<" + "long line ".repeat(10e3) + ">", [scroll(100), EditorView.lineWrapping]) cm.measure() let text = cm.contentDOM.textContent! ist(text.length, cm.state.doc.length, "<") ist(text.indexOf("<"), -1, ">") ist(cm.visibleRanges.reduce((s, r) => s + r.to - r.from, 0), cm.viewport.to - cm.viewport.from, "<") cm.scrollDOM.scrollTop = cm.scrollDOM.scrollHeight / 2 + 100 cm.dispatch({selection: {anchor: cm.state.doc.length >> 1}}) cm.measure() text = cm.contentDOM.textContent! ist(text.length, cm.state.doc.length, "<") ist(text.indexOf("<"), -1) ist(text.indexOf(">"), -1) cm.scrollDOM.scrollTop = cm.scrollDOM.scrollHeight cm.measure() text = cm.contentDOM.textContent! ist(text.length, cm.state.doc.length, "<") ist(text.indexOf(">"), -1, ">") }) it("properly attaches styles in shadow roots", () => { let ws = document.querySelector("#workspace")! let wrap = ws.appendChild(document.createElement("div")) if (!wrap.attachShadow) return let shadow = wrap.attachShadow({mode: "open"}) let editor = new EditorView({root: shadow}) shadow.appendChild(editor.dom) editor.measure() ist(getComputedStyle(editor.dom).display, "flex") wrap.remove() }) it("allows editor attributes to override each other", () => { let cm = tempView("", [ EditorView.contentAttributes.of({"data-x": "x"}), EditorView.contentAttributes.of({"data-x": "y"}), Prec.highest(EditorView.contentAttributes.of({"data-x": "z"})), ]) ist(cm.contentDOM.getAttribute("data-x"), "z") }) it("updates height info when a widget changes size", async () => { let widget = new class extends WidgetType { toDOM() { let d = document.createElement("div") d.style.height = "10px" setTimeout(() => d.style.height = "30px", 5) return d } } let cm = tempView("a\nb\nc\nd", [ EditorView.decorations.of(Decoration.set(Decoration.widget({widget, block: true, side: 1}).range(1))) ]) cm.measure() await later(75) let line2 = cm.viewportLineBlocks[1], dom2 = cm.contentDOM.querySelectorAll(".cm-line")[1] ist(Math.abs(cm.documentTop + line2.top - (dom2 as HTMLElement).getBoundingClientRect().top), 1, "<") }) }) view-6.26.3/test/webtest-events.ts000066400000000000000000000052321460616253600171030ustar00rootroot00000000000000import {tempView} from "./tempview.js" import {EditorView, ViewPlugin} from "@codemirror/view" import {Prec, Compartment, StateEffect} from "@codemirror/state" import ist from "ist" function signal(view: EditorView, type: string, props?: {[name: string]: any}) { view.contentDOM.dispatchEvent(new Event(type, props)) } class Log { events: string[] = [] handler(tag?: string, result = false) { return (event: Event) => { this.events.push(event.type + (tag ? "-" + tag : "")); return result } } toString() { return this.events.join(" ") } } describe("EditorView events", () => { it("runs built-in handlers", () => { let cm = tempView() signal(cm, "focus") ist(cm.inputState.lastFocusTime, 0, ">") }) it("runs custom handlers", () => { let log = new Log let cm = tempView("", EditorView.domEventHandlers({focus: log.handler()})) signal(cm, "focus") ist(log.toString(), "focus") }) it("runs handlers in the right order", () => { let log = new Log let cm = tempView("", [ EditorView.domEventHandlers({x: log.handler("a"), y: log.handler("?")}), EditorView.domEventHandlers({x: log.handler("b")}), Prec.high(EditorView.domEventHandlers({x: log.handler("c")})) ]) signal(cm, "x") ist(log.toString(), "x-c x-a x-b") }) it("stops running handlers on handled events", () => { let log = new Log let cm = tempView("", [ EditorView.domEventHandlers({x: log.handler("a", true)}), EditorView.domEventHandlers({x: log.handler("b")}) ]) signal(cm, "x") ist(log.toString(), "x-a") }) it("runs observers before handlers", () => { let log = new Log let cm = tempView("", [ EditorView.domEventHandlers({x: log.handler("a")}), EditorView.domEventObservers({x: log.handler("b", true)}) ]) signal(cm, "x") ist(log.toString(), "x-b x-a") }) it("can dynamically change event handlers", () => { let log = new Log, comp = new Compartment let cm = tempView("", [ EditorView.domEventHandlers({x: log.handler("a")}), comp.of(EditorView.domEventHandlers({x: log.handler("b")})) ]) signal(cm, "x") cm.dispatch({effects: [ comp.reconfigure([]), StateEffect.appendConfig.of(EditorView.domEventHandlers({y: log.handler("c")})) ]}) signal(cm, "x") signal(cm, "y") signal(cm, "z") ist(log.toString(), "x-a x-b x-a y-c") }) it("runs handlers with this bound to the plugin", () => { let called, cm = tempView("", [ ViewPlugin.define(() => ({x: "!"}), { eventHandlers: { x() { called = "yes " + this?.x } } }) ]) signal(cm, "x") ist(called, "yes !") }) }) view-6.26.3/test/webtest-extension.ts000066400000000000000000000054161460616253600176170ustar00rootroot00000000000000import {tempView} from "./tempview.js" import {Text, EditorState, Compartment} from "@codemirror/state" import {EditorView, ViewPlugin, ViewUpdate} from "@codemirror/view" import ist from "ist" describe("EditorView extension", () => { it("calls update when the viewport changes", () => { let viewports: {from: number, to: number}[] = [] let plugin = ViewPlugin.define(view => { viewports.push(view.viewport) return { update(update: ViewUpdate) { if (update.viewportChanged) viewports.push(update.view.viewport) } } }) let cm = tempView("x\n".repeat(500), [plugin]) ist(viewports.length, 1) ist(viewports[0].from, 0) cm.dom.style.height = "300px" cm.scrollDOM.style.overflow = "auto" cm.scrollDOM.scrollTop = 2000 cm.measure() ist(viewports.length, 2, ">=") ist(viewports[1].from, 0, ">") ist(viewports[1].to, viewports[0].from, ">") cm.scrollDOM.scrollTop = 4000 let curLen = viewports.length cm.measure() ist(viewports.length, curLen, ">") }) it("calls update on plugins", () => { let updates = 0, prevDoc: Text let plugin = ViewPlugin.define(view => { prevDoc = view.state.doc return { update(update: ViewUpdate) { ist(update.startState.doc, prevDoc) ist(update.state.doc, cm.state.doc) prevDoc = cm.state.doc updates++ } } }) let cm = tempView("xyz", [plugin]) ist(updates, 0) cm.dispatch({changes: {from: 1, to: 2, insert: "u"}}) ist(updates, 1) cm.dispatch({selection: {anchor: 3}}) ist(updates, 2) }) it("allows content attributes to be changed through effects", () => { let cm = tempView("", [EditorView.contentAttributes.of({spellcheck: "true"})]) ist(cm.contentDOM.spellcheck, true) }) it("allows editor attributes to be changed through effects", () => { let cm = tempView("", [EditorView.editorAttributes.of({class: "something"})]) ist(cm.dom.classList.contains("something")) ist(cm.dom.classList.contains("cm-editor")) }) it("redraws the view when phrases change", () => { let plugin = ViewPlugin.fromClass(class { elt: HTMLElement constructor(view: EditorView) { let elt = this.elt = view.dom.appendChild(document.createElement("div")) elt.textContent = view.state.phrase("Hello") elt.style.position = "absolute" elt.className = "greeting" } destroy() { this.elt.remove() } }) let lang = new Compartment let cm = tempView("one", [plugin, lang.of([])]) ist(cm.dom.querySelector(".greeting")!.textContent, "Hello") cm.dispatch({effects: lang.reconfigure(EditorState.phrases.of({Hello: "Bonjour"}))}) ist(cm.dom.querySelector(".greeting")!.textContent, "Bonjour") }) }) view-6.26.3/test/webtest-hover.ts000066400000000000000000000062721460616253600167270ustar00rootroot00000000000000import {EditorState} from "@codemirror/state" import {EditorView, hoverTooltip} from "@codemirror/view" import ist from "ist" async function waitForSuccess(assert: () => void) { for (let i = 0; i < 20; i++) { await new Promise(resolve => setTimeout(() => resolve(), 50)) try { assert() return } catch { } } // final try assert() } function setupHover(...tooltips: Array void}>) { const testText = "test" const hoverTooltips = tooltips.map(x => { const {text, start, end, destroy} = typeof x === "string" ? {text: x, start: 0, end: testText.length - 1, destroy: undefined} : x return hoverTooltip((_, pos) => { if (pos < start || pos > end) return null return {pos, create: () => { const dom = document.createElement("div") dom.innerText = text return {dom, destroy} }} }, {hoverTime: 10}) }) const root = document.body.querySelector("#workspace")! return new EditorView({state: EditorState.create({doc: testText, extensions: hoverTooltips}), parent: root}) } function mouseMove(view: EditorView, pos = 0) { const line = view.dom.querySelector(".cm-line")! const {top, left} = view.coordsAtPos(pos)! line.dispatchEvent(new MouseEvent("mousemove", {bubbles: true, clientX: left + 1, clientY: top + 1})) } function expectTooltip(view: EditorView, html: string) { return waitForSuccess(() => { const tooltip = view.dom.querySelector(".cm-tooltip")! ist(tooltip) ist(tooltip.classList.contains("cm-tooltip")) ist(tooltip.classList.contains("cm-tooltip-hover")) ist(tooltip.innerHTML, html) }) } describe("hoverTooltip", () => { it("renders one tooltip view in container", async () => { let view = setupHover("test") mouseMove(view) await expectTooltip(view, '
test
') view.destroy() }), it("renders two tooltip views in container", async () => { let view = setupHover("test1", "test2") mouseMove(view) await expectTooltip(view, '
test1
' + '
test2
') view.destroy() }) it("adds tooltip view if mouse moves into the range", async () => { let view = setupHover( {text: "add", start: 2, end: 4}, {text: "keep", start: 0, end: 4} ) mouseMove(view, 0) await expectTooltip(view, '
keep
') mouseMove(view, 3) await expectTooltip(view, '
add
' + '
keep
') view.destroy() }) it("removes tooltip view if mouse moves outside of the range", async () => { let destroyed = false let view = setupHover( {text: "remove", start: 0, end: 2, destroy: () => destroyed = true}, {text: "keep", start: 0, end: 4} ) mouseMove(view, 0) await expectTooltip(view, '
remove
' + '
keep
') mouseMove(view, 3) await expectTooltip(view, '
keep
') ist(destroyed, true) view.destroy() }) }) view-6.26.3/test/webtest-motion.ts000066400000000000000000000066361460616253600171150ustar00rootroot00000000000000import {tempView, requireFocus} from "./tempview.js" import ist from "ist" function setDOMSel(node: Node, offset: number) { let range = document.createRange() range.setEnd(node, offset) range.setStart(node, offset) let sel = window.getSelection()! sel.removeAllRanges() sel.addRange(range) } function textNode(node: Node, text: string): Text | null { if (node.nodeType == 3) { if (node.nodeValue == text) return node as Text } else if (node.nodeType == 1) { for (let ch = node.firstChild; ch; ch = ch.nextSibling) { let found = textNode(ch, text) if (found) return found } } return null } function domIndex(node: Node): number { for (var index = 0;; index++) { node = node.previousSibling! if (!node) return index } } describe("EditorView selection", () => { it("can read the DOM selection", () => { let cm = requireFocus(tempView("one\n\nthree")) function test(node: Node, offset: number, expected: number) { setDOMSel(node, offset) cm.contentDOM.focus() cm.observer.flush() ist(cm.state.selection.main.head, expected) } let one = textNode(cm.contentDOM, "one")! let three = textNode(cm.contentDOM, "three")! test(one, 0, 0) test(one, 1, 1) test(one, 3, 3) test(one.parentNode!, domIndex(one), 0) test(one.parentNode!, domIndex(one) + 1, 3) test(cm.contentDOM.childNodes[1], 0, 4) test(three, 0, 5) test(three, 2, 7) test(three.parentNode!, domIndex(three), 5) test(three.parentNode!, domIndex(three) + 1, 10) }) it("syncs the DOM selection with the editor selection", () => { let cm = requireFocus(tempView("abc\n\ndef")) function test(pos: number, node: Node, offset: number) { cm.dispatch({selection: {anchor: pos}}) let sel = window.getSelection()! ist(isEquivalentPosition(node, offset, sel.focusNode, sel.focusOffset)) } let abc = textNode(cm.contentDOM, "abc")! let def = textNode(cm.contentDOM, "def")! test(0, abc.parentNode!, domIndex(abc)) test(1, abc, 1) test(2, abc, 2) test(3, abc.parentNode!, domIndex(abc) + 1) test(4, cm.contentDOM.childNodes[1], 0) test(5, def.parentNode!, domIndex(def)) test(6, def, 1) test(8, def.parentNode!, domIndex(def) + 1) }) }) function isEquivalentPosition(node: Node, off: number, targetNode: Node | null, targetOff: number): boolean { function scanFor(node: Node, off: number, targetNode: Node, targetOff: number, dir: -1 | 1): boolean { for (;;) { if (node == targetNode && off == targetOff) return true if (off == (dir < 0 ? 0 : maxOffset(node))) { if (node.nodeName == "DIV") return false let parent = node.parentNode if (!parent || parent.nodeType != 1) return false off = domIndex(node) + (dir < 0 ? 0 : 1) node = parent } else if (node.nodeType == 1) { node = node.childNodes[off + (dir < 0 ? -1 : 0)] off = dir < 0 ? maxOffset(node) : 0 } else { return false } } } function domIndex(node: Node): number { for (var index = 0;; index++) { node = node.previousSibling! if (!node) return index } } function maxOffset(node: Node): number { return node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length } return targetNode ? (scanFor(node, off, targetNode, targetOff, -1) || scanFor(node, off, targetNode, targetOff, 1)) : false }