pax_global_header00006660000000000000000000000064152713254310014515gustar00rootroot0000000000000052 comment=2a07ebc03ccfb526b43e60297465129a2cee8ca6 prosemirror-view/000077500000000000000000000000001527132543100144145ustar00rootroot00000000000000prosemirror-view/.gitignore000066400000000000000000000000651527132543100164050ustar00rootroot00000000000000/node_modules .tern-port /dist /notes.txt /test/*.js prosemirror-view/.npmignore000066400000000000000000000000371527132543100164130ustar00rootroot00000000000000/node_modules .tern-port /test prosemirror-view/.npmrc000066400000000000000000000000231527132543100155270ustar00rootroot00000000000000package-lock=false prosemirror-view/.tern-project000066400000000000000000000001571527132543100170340ustar00rootroot00000000000000{ "libs": ["browser"], "plugins": { "node": {}, "complete_strings": {}, "es_modules": {} } } prosemirror-view/CHANGELOG.md000066400000000000000000002615201527132543100162330ustar00rootroot00000000000000## 1.42.9 (2026-10-31) ### Bug fixes Avoid a crash in the workaround for dropped backspace events on Chrome Android. Fix an issue where `handleKeyDown` could be called multiple time for a single backspace press. Fix an issue where the editor could dispatch Enter or Backspace keys during composition. Remove an obsolete kludge that could interfere with the selection on focus. Fix an issue that could cause a selection change right after focus to be overwritten by the old selection. ## 1.42.8 (2026-10-24) ### Bug fixes Fix a regression since 1.42.6 that causes widgets that render as non-element nodes to no longer be wrapped, and document fragment widgets to be entirely dropped. ## 1.42.7 (2026-10-22) ### Bug fixes Fix an issue where some kinds of changes inside inline nodes with content were inappropriately treated as backspace events. Fix some selection issues in a shadow root on Safari by working around the relevant browser bugs. Improve cursor motion across non-text in blocks that don't have the same text direction as the editor as a whole. Fix an issue where decoration sets with lots of node decorations inside a single parent would require quadratic time to update. Fix a regression in `posAtDOM` (and DOM selection reading) when the position is outside the nearest node's content DOM, but not directly in its outer element. Avoid a selection read in DOM flushing when the editor doesn't have focus. ## 1.42.6 (2026-09-25) ### Bug fixes Work around an issue in Firefox where horizontal cursor motion would refuse to stop between an inline atom node and the start of a line. On Safari and Firefox, work around skipped cursor positions and odd cursor drawing by putting a dummy node in front of atomic inline nodes when they appear at the start of the block or right after another atom. Fix an issue where `posAtDOM` would incorrectly return a position before a node when querying a DOM position outside of its content element but inside of a wrapping element. Work around a Firefox bug that causes content wrapped in a scrollable element to be skipped over in keyboard cursor motion. Improve performance of non-DOM-connected editors by avoiding DOM selection queries. ## 1.42.5 (2026-09-21) ### Bug fixes Fix an issue where some kinds of deletions on Chrome Android were interpreted as Enter presses. Fix a regression where some types of Enter presses on Android would not go through the Enter key handling. ## 1.42.4 (2026-09-18) ### Bug fixes Prevent uneditable elements rendered as part of node or mark view from blocking cursor motion. Fix a bug that could break some types of composition input starting with a non-empty selection. ## 1.42.3 (2026-08-25) ### Bug fixes Don't set `contenteditable=false` on widgets that already have a `contenteditable` attribute. Fix a bug that broke pasting when there was a default trusted type policy defined without a `createHTML` function. Run attribute validators on attributes provided via slice context in clipboard content. This closes an XSS vulnerability (found by Pedro Paniago). ## 1.42.2 (2026-07-24) ### Bug fixes Enable a workaround for backspace not taking effect on Android for Firefox as well as Chrome (for which it already kicked in). Work around the situation where Chrome/Webkit replace a node's content DOM with a completely different element. ## 1.42.1 (2026-07-11) ### Bug fixes Don't scroll to a bogus position when trying to scroll an element that has no layout into view. ## 1.42.0 (2026-07-01) ### Bug fixes The clipboard content parser now inlines simple styles defined in the pasted HTML before parsing it, since some applications output their styling that way. ### New features Mark views now also support an `update` method. ## 1.41.9 (2026-06-10) ### Bug fixes Allow triple-click drag selection. Adjust an existing workaround to work around the fact that Chrome no longer fires key events for backspace during composition. ## 1.41.8 (2026-04-02) ### Bug fixes Make the types reflect that mark and node views should render elements, not any DOM node. Fix a bug causing `dragCopies` to be ignored when it returned false. ## 1.41.7 (2026-03-18) ### Bug fixes Work around a Chrome bug where it sometimes inserts a random `
` node when you press backspace before a widget. Fix a crash that could happen when deleting content during a node drag. ## 1.41.6 (2026-02-06) ### Bug fixes Fix an issue where backspacing content before a flexbox or grid styled widget would insert a bogus line break on Firefox. ## 1.41.5 (2026-01-14) ### Bug fixes Work around an issue where, after some kinds of changes, Chrome misreports the cursor position and breaks composition. Improve DOM update behavior when deleting three or more nodes right before a mark, which would unnecessarily re-render the entire mark. Work around a newly introduced bug in Safari where composition in an empty table cell causes it to move the text out of the cell into the row element. ## 1.41.4 (2025-12-01) ### Bug fixes `EditorView.dragging` is now only cleared after the drop event has entirely been handled. Add a workaround for an issue where Chrome and Safari will, if you backspace out the last text before an inline-flex node, insert a bogus hard break. Fix a composition problem on Chrome Windows when starting a composition before an absolutely positioned widget. ## 1.41.3 (2025-10-09) ### Bug fixes Fix a bug where typing when the selection crosses inline node boundaries was ignored in some situations. ## 1.41.2 (2025-09-29) ### Bug fixes Fix an issue where some Backspace presses with SwiftKey on Android would get interpreted as Enter. ## 1.41.1 (2025-09-19) ### Bug fixes Consistently set `draggable` on a node's inner DOM representation, not a decoration wrapper element. ## 1.41.0 (2025-09-06) ### Bug fixes Make sure `transformPasted` is properly called when pasting into a code block. Fix an issue where the editor didn't properly set the DOM selection in a shadow DOM on Safari in some situations. Work around a Safari issue that broke composition right after changing the set of active marks. ### New features Pass an argument to `transformPasted` that tells the handler whether the paste is plain text. Call transformPasted when pasting into a code block ## 1.40.1 (2025-07-18) ### Bug fixes Fix an issue where, for some Android virtual keyboards, joining textblocks that contain an image with Backspace would run the handler for Enter instead. Fix an issue causing `posAtCoords` to get confused by table cells sticking out of their parent row elements. ## 1.40.0 (2025-06-02) ### Bug fixes Suppress lint warnings about dereferencing methods by making `EditorView.dispatch` a property instead of a method. ### New features The `"handleTextInput"` prop now takes an additional argument that can be used to generate the default transaction for that text change. Widget decorations now support a `relaxedSide` option which can be used to allow the DOM cursor to appear at either side. ## 1.39.3 (2025-05-14) ### Bug fixes Fix an issue where clicks using different mouse buttons could be grouped into a single double- or triple-click. Only delete the selection when starting a composition when it crosses block boundaries. ## 1.39.2 (2025-04-22) ### Bug fixes Default to using `trustedTypes.defaultPolicy` for clipboard input, when available. Fix an issue where pasting multiple lines from the virtual keyboard on Android would have only the effect of an enter press. ## 1.39.1 (2025-04-04) ## 1.39.0 (2025-04-04) ### Bug fixes Fix an issue where, with some types of schemas, pressing Enter on Android with a Chinese keyboard wouldn't properly detect the Enter press. ### New features The new `dragCopies` prop can be used to configure whether a drag event inside the editor copies or moves content. ## 1.38.1 (2025-03-04) ### Bug fixes Fix a regression in 1.38.0 that breaks scrolling back to the selection after Chrome tries to reset it on focus. Remove some internally used types from the public type declaration files. Include file extension in web test local imports Fix an issue where scrolling the cursor into view could inappropriately scroll parent nodes of absolutely positioned elements. ## 1.38.0 (2025-02-12) ### New features The clipboard serialization logic is now available as a `serializeToClipboard` method on the view object. ## 1.37.2 (2025-01-22) ### Bug fixes Fix a bug that caused `EditorView.nodeDOM` to sometimes not return a DOM node when querying a node next to a widget. Fix an issue where an editor would, when scrolling the selection into view, try to do so even if the selection wasn't in that editor. ## 1.37.1 (2024-12-11) ### Bug fixes Fix an issue where `display: content` elements could break `posAtCoords`. Work around an issue with Chrome reporting the selection incorrectly during composition. ## 1.37.0 (2024-11-23) ### Bug fixes Fix an issue on Safari where the (incorrect) top-level selection was used, instead of the workaround that also works inside shadow roots. ### New features Mark views can now provide an `ignoreMutation` method. Add ViewMutationRecord to doc template ## 1.36.0 (2024-11-05) ### Bug fixes Don't recreated `TrustedTypePolicy` objects for every paste. ### New features Add support for a `destroy` method on custom mark views. Add MarkView to the doc template ## 1.35.0 (2024-10-29) ### New features The new `NodeView.multiType` flag is now required to opt into handling different node types in a single node view. It used to be the default, but this tripped too many people up. ## 1.34.3 (2024-09-19) ### Bug fixes Fix an issue that would, during some types of document updates, leave the document DOM in an improper form. Handle the case where drag-selecting in Firefox creates a selection with multiple distinct ranges. ## 1.34.2 (2024-09-03) ### Bug fixes Fix pasting in Chrome with the require-trusted-types-for CSP enabled. Fix an issue where, for some kinds of IME on Windows, pressing Enter while in a composition would execute the resulting command on the wrong selection, erasing some typed content. ## 1.34.1 (2024-08-26) ### Bug fixes Fix null dereferences caused by the behavior of Chrome's newly supported `caretPositionFromPoint` method. Fix an issue where on Android, with GBoard, enter presses in code blocks while a spell correction was selected didn't insert a new line. ## 1.34.0 (2024-08-23) ### New features The `DecorationGroup` interface now has a `forEachSet` method to iterate though the contained decoration sets. ## 1.33.11 (2024-08-19) ### Bug fixes Delete the selection on composition start when on a mark boundary to force Chrome to apply the correct style to the composition. When the browser starts a composition and the selection spans across blocks, start by clearing it to make sure the composed text node lands in a valid position. ## 1.33.10 (2024-08-13) ### Bug fixes Prevent triple clicks and clicks on atom nodes from dispatching a selection update when the new selection is equal to the current selection. Don't crash when `window.getSelection()` returns null. Create a cursor wrapper when starting a composition with a selection that spans mark boundaries, to avoid odd DOM forms created by Chrome in such a situation. ## 1.33.9 (2024-07-18) ### Bug fixes Fix an issue where dragging an image on Chrome versions before 121 failed to put the image file in the clipboard data. Enable cross-site scripting protection hack when rendering nodes and marks inside the editor. ## 1.33.8 (2024-06-11) ### Bug fixes When composition happens with the cursor between two text nodes, use the last mutated one to guess which one is being changed. ## 1.33.7 (2024-05-31) ### Bug fixes Ignore stray `
` nodes added to parent list elements when backspacing near uneditable content in Firefox. Fix a crash where the editor tried to call `removeAttribute` on a text node. Fix an issue where coordinates near nested blocks could inappropriately be assigned to be inside those blocks. ## 1.33.6 (2024-04-30) ### Bug fixes Make sure the content slice stored for an in-editor drag is the one that was transformed by `transformCopied`. ## 1.33.5 (2024-04-22) ### Bug fixes Fix a regression in composition handling in 1.33.3 that occurs for some IMEs when starting composition on an empty line. ## 1.33.4 (2024-04-08) ### Bug fixes When available, use `Selection.getComposedRanges` on Safari to find the selection inside a shadow DOM. ## 1.33.3 (2024-03-22) ### Bug fixes Further improve preserving of compositions for IME systems that keep the cursor before the composed text. ## 1.33.2 (2024-03-21) ### Bug fixes Improve Android backspace detection when the native effect is to delete an entire block element. Fix a bug where reconfiguring node views caused the editor to lose focus. Fix an issue where some commands didn't properly scroll the cursor into view on Mobile Safari. Fix a bug where starting a composition with stored marks or after a noninclusive mark, with an IME that keeps the cursor at the start of the composed text (like many Chinese IMEs) disrupted composition on Chrome. Fix a memory leak where a (single) destroyed editor could be kept alive through a reused `Range` object. ## 1.33.1 (2024-02-15) ### Bug fixes Fix a type error in the published type declarations in 1.33.0. ## 1.33.0 (2024-02-15) ### New features The `DecorationSource.forChild` method is now public. ## 1.32.7 (2023-12-18) ### Bug fixes Fix a regression that caused widget decorations directly after a node to not get drawn in some situations. ## 1.32.6 (2023-12-08) ### Bug fixes Fix a bug that could cause view updates to crash in rare situations where inline nodes were added around decorations. ## 1.32.5 (2023-12-04) ### Bug fixes Fix a bug in `DecorationSet.map` that, in specific cases when mapping through groups of changes, move decorations to the wrong position. ## 1.32.4 (2023-11-08) ### Bug fixes Fix a case where a DOM change involving astal characters could create odd steps that started and ended in the middle of UTF16 surrogate pairs. Dragging a draggable node will no longer implicitly select it. ## 1.32.3 (2023-11-01) ### Bug fixes Fix a bug where, in some situations, pasting plain text pasted HTML markup. ## 1.32.2 (2023-10-27) ### Bug fixes Check for text/uri-list data on the clipboard or in the drop event when pasting or dropping content, since some types of copying on Apple devices use this format. ## 1.32.1 (2023-10-13) ### Bug fixes Work around a Chrome issue where it didn't draw a selection for inline leaf nodes when selecting across them. Override the native behavior when shift-selecting horizontally across non-text inline nodes, since most browsers behave poorly here. ## 1.32.0 (2023-09-27) ### Bug fixes Work around an issue on Chrome where, when clicking above the right side of an uneditable node, the cursor would be put after the node. Fix a bug that could corrupt the composition when it occurs next to text identical to the composed characters. ### New features The new `EditorView.updateRoot` method can be used to make the editor update its DOM root when it is moved to a new document or shadow tree. ## 1.31.8 (2023-09-12) ### Bug fixes Fix a bug that broke arrow motion near uneditable nodes on Chrome in some situations. Fix an issue that broke backspace at the end of a textblock that ended in a newline on Chrome. Fix a crash in the way inline decorations that ended right after a node end position were drawn. ## 1.31.7 (2023-07-28) ### Bug fixes Fix a bug where, when a compositionend event was received before the composition's final changes were processed, the resulting transaction didn't have its composition metadata set. Fix an issue that caused cursor motion to stop at uneditable nodes that aren't widget decorations. When scrolling an element higher than the viewport into view, make sure its top is visible. ## 1.31.6 (2023-07-14) ### Bug fixes Fix a bug that caused `posAtCoords` to return incorrect results for coordinates over inline-decorated text. Don't try to scroll fixed-positioned elements into view by scrolling their parent elements. ## 1.31.5 (2023-06-19) ### Bug fixes Fix a bug where the editor accidentally blocked shift-delete when the whole document is selected. Bring back workaround for an issue where Firefox draws the cursor in the wrong place in a code block. Fix an issue where Shift-Insert pastes pasted plain text (because Shift was being held). Fix an issue where selecting across paragraph and starting a composition would update the state as if Enter was pressed and disrupt the composition. ## 1.31.4 (2023-05-31) ### Bug fixes Make sure `destroy` is called on node views that are replaced by changing a node's markup. ## 1.31.3 (2023-05-17) ### Bug fixes Fix an issue that could cause the clipboard parser to create invalid slices when parsing content containing isolating nodes. Include CommonJS type declarations in the package to please new TypeScript resolution settings. ## 1.31.2 (2023-05-10) ### Bug fixes Fix a bug that broke backspace after empty lines in code blocks on Firefox. Make it possible for the `attributes` prop to override the `translate` attribute. ## 1.31.1 (2023-05-01) ### Bug fixes Fix a bug that could cause a view crash on some kind of document updates. ## 1.31.0 (2023-04-26) ### Bug fixes Fix an issue where, when the browser failed to return a cursor for a set of coordinates (as in a shadow root in Chrome) the library's own algorithm worked poorly for coordinates between lines in a textblock. Improve performance of changing a parent node (by type or attributes) without modifying its children by reusing the DOM of the child nodes. Fix an issue where, in right-to-left context, the left and right arrow keys could get stuck on a selectable leaf node because the editor was moving the selection in the wrong direction. Fix a regression in `coordsAtPos` when querying the position of a line wrap. ### New features Attach a `"composition"` meta field to transactions generated from DOM changes. ## 1.30.2 (2023-03-13) ### Bug fixes Fix an issue where `posAtCoords` could incorrectly return positions outside of a table when querying a coordinate inside it. Update the type of the `getPos` callback to include undefined in its return type. Update broken link (ViewProps -> EditorProps) Also detect iOS enter when the new block type is a less common node Fix an issue where Enter on iOS would sometimes not be handled by the library. ## 1.30.1 (2023-02-04) ### Bug fixes Fix a bug that caused `posAtCoords` to always return the position after a leaf node when querying a position on top of the node's element. Fix an issue where, when putting focus on the editor by clicking at the very start of the document, the editor would restore a previous selection. ## 1.30.0 (2023-01-19) ### New features `EditorView` now has `pasteHTML` and `pasteText` methods which initiate paste handling. ## 1.29.2 (2023-01-17) ### Bug fixes Make sure enter key handlers are called on Chrome Android when the cursor is in a position where the native behavior of Enter doesn't change the document. Fix a crash that could occur when starting a composition at the end of a non-inclusive mark. Fix a bug in (vertical) `endOfTextblock` that caused it to return falsely positive results inside textblocks that had a separate `contentDOM`. ## 1.29.1 (2022-11-14) ### Bug fixes Fix a problem when returning decorations from an outer editor from the `decorations` prop. ## 1.29.0 (2022-10-12) ### Bug fixes Fix an issue where HTML comments added to the clipboard by the OS throw off the clipboard parser. Make sure Chrome Android's native behavior on pressing Enter in an empty nested list item is replaced by the result of the editor's Enter key handler. ### New features The `transformPastedHTML`, `transformPastedText`, `transformPasted`, `transformCopied`, and `clipboardTextSerializer` props are now passed the view instance as last argument. ## 1.28.3 (2022-10-05) ### Bug fixes Work around the five-year-old Safari bug where it won't accurately report the selection inside shadow roots, which would break ProseMirror when put in shadow DOM. ## 1.28.2 (2022-09-24) ### Bug fixes Avoid a crash in selection updates in a hidden editor on Safari. ## 1.28.1 (2022-09-21) ### Bug fixes Fix an issue where the editor would reset its scroll position to the top of the document for any state reconfiguration, even if the document was unchanged. ## 1.28.0 (2022-09-09) ### Bug fixes Fix an issue where the editor wouldn't realize it was focused on IE when IE's resize controls are on an element inside the content. ### New features The new `transformCopied` prop can be used to transform a copied/cut slice before it is serialized to the clipboard. ## 1.27.2 (2022-08-19) ### Bug fixes Fix an issue where `DecorationSet.map`, given a mapping with multiple steps, could in some cases corrupt the decorations. Stop calling `handleClick` when the mouse moved but failed to fire mousemove events (for example when dragging a scrollbar). ## 1.27.1 (2022-08-16) ### Bug fixes Fix a bug in `DecorationSet.map` that could cause invalid mapping when step maps contain multiple replaced ranges. Make sure that when the editor receives focus via tab or calling `.focus()` on its DOM element, the existing selection is restored. Avoid collapsing of whitespace in Firefox when the editor doesn't have `white-space` set to `pre-wrap`. ## 1.27.0 (2022-07-20) ### Bug fixes Fix a bug that in some situations caused composition to be disrupted on Chrome due to a workaround for another browser issue activating inappropriately. ### New features `EditorProps` now takes an optional type parameter that indicates the type of `this` in prop functions. ## 1.26.5 (2022-07-07) ### Bug fixes Fix an issue where a composition happening at the same time as widget insertion/removal could corrupt the rendered DOM. Fix a problem where `coordsAtPos` would look inside the DOM for atomic nodes, and sometimes return very odd positions that way. ## 1.26.4 (2022-07-04) ### Bug fixes Avoid spurious type errors on `handleDOMEvents` by widening the type for unknown event objects. ## 1.26.3 (2022-06-29) ### Bug fixes Work around a Safari bug where mutation records get delivered even though the observer wasn't observing when the mutations happened. ## 1.26.2 (2022-06-22) ### Bug fixes Mark prosemirror.css as having side effects in package.json. ## 1.26.1 (2022-06-13) ### Bug fixes Fix incorrect types for `handleDrop` and `decorations`. Document open properties on widget specs ## 1.26.0 (2022-06-07) ### New features Export the `NodeViewConstructor` and `MarkViewConstructor` types. Include Node/MarkViewConstructor in doc template ## 1.25.0 (2022-06-01) ### New features Mark views can now be specified with a separate editor prop, `markViews`, to make it easier to get the types right. ## 1.24.1 (2022-05-30) ### Bug fixes Fix a regression that caused the key handler to crash. ## 1.24.0 (2022-05-30) ### Bug fixes Fix an issue where bogus table wrappers could be added when copy-pasting table content nodes within an editor. Fix a crash that could occur when two textblocks were joined in the editor DOM. Treat ctrl-b/f/p/n on macOS similar to arrow key presses, anticipating cursor motion. ### New features Include TypeScript type declarations. ## 1.23.13 (2022-04-26) ### Bug fixes Work around a hidden cursor issue in Chrome when a textblock ends in an uneditable node wrapped in a mark. ## 1.23.12 (2022-04-08) ### Bug fixes Fix an issue where enter on Chrome Android could, in textblock nodes rendered with an inner content element, delete the text after the cursor. ## 1.23.11 (2022-03-31) ### Bug fixes Fix an issue where a node view with a separate content wrapper node could sometimes lose its content on Chrome when backspacing out content due to unexpected DOM element recreation. ## 1.23.10 (2022-03-22) ### Bug fixes Fix a crash in `DecorationSet.map` that could occur with some kinds of lift transformations. ## 1.23.9 (2022-03-11) ### Bug fixes Make sure screen readers don't read the `` nodes added as kludge for cursor behavior. ## 1.23.8 (2022-03-10) ### Bug fixes Fix an issue where the editor needlessly interrupted composition with IME systems that keep the cursor at the start of the composition, such as some Pinyin input methods. ## 1.23.7 (2022-02-25) ### Bug fixes Fix a crash when a view was being destroyed during input reading. Fix a crash on Firefox when replacing text in some specific types of document structure. ## 1.23.6 (2022-01-13) ### Bug fixes Fix an issue that could cause pieces of DOM to not be synchronized with the document after some kind of changes around marks. Fix a bug where marks rendered with nested elements would in some situations suppress new input. Disable Chrome-specific drag-selection workaround in non-Chrome browsers because it affected table cell selection in Safari. ## 1.23.5 (2021-12-27) ### Bug fixes Use the `whitespace` node prop where appropriate. ## 1.23.4 (2021-12-22) ### Bug fixes Improve the way the editor handles the mess of events produced when pressing Enter before a word that was just typed on Chrome + GBoard. Fix an issue where compositions right before another instance of the composed text could cause the editor to crash. Fix an issue where, if decorations changed during a pointer drag selection, the selection anchor might move around inappropriately. ## 1.23.3 (2021-11-26) ### Bug fixes The clipboard parser will no longer drop trailing `
` nodes that appear in an inline parent element. ## 1.23.2 (2021-11-19) ### Bug fixes Avoid some unnecessary node redraws when marks are present in sibling nodes. ## 1.23.1 (2021-11-15) ### Bug fixes Restore accidentally reduced lookahead distance in view updating algorithm. ## 1.23.0 (2021-11-11) ### Bug fixes When parsing clipboard content, ignore trailing BR nodes that look like they might be there as a contenteditable kludge. ### New features `EditorView` now exposes an `isDestroyed` property that can be used to test if the view has been destroyed. ## 1.22.0 (2021-11-08) ### Bug fixes Fix an issue where some types of node decoration changes could cause an unnecessary cascade of node redraws. ### New features Widget decorations now accept a `destroy` option, which will be called when the widget is removed from the view. ## 1.21.0 (2021-10-29) ### Bug fixes Fix issue where recent Mobile Safari versions weren't treated as Mac platforms. ### New features Multiple `style` properties provided through the `attributes` prop are now merged. Adjust mac detection for recent changes to navigator.platform on iOS ## 1.20.3 (2021-10-13) ### Bug fixes Stop removing leading/trailing whitespace from pasted plain text. Fix an issue that could cause invalid content to be produced when pasting HTML with isolating nodes in it. ## 1.20.2 (2021-10-07) ### Bug fixes Fix a crash when pasting whitespace-only content as text. ## 1.20.1 (2021-09-09) ### Bug fixes The library accidentally allowed node decorations on text nodes. It no longer does. Fix an issue on Chrome and Safari where coordinates for positions between uneditable nodes and the end of a textblock would return zero-height rectangles. Fix a bug where vertical `endOfTextblock` queries could inappropriately return true when a small line height is used. ## 1.20.0 (2021-09-03) ### New features It is now possible to pass plugins directly to the view with the `plugins` direct prop. ## 1.19.3 (2021-08-20) ### Bug fixes Fix an issue where generic styles for elements could cause separator nodes created by the editor to impact layout. Fix an issue where moving to another tab and back could clear a node selection. ## 1.19.2 (2021-08-19) ### Bug fixes Avoid some bugs around drag-selecting by delaying synchronization between the DOM and the state selection until the end of the drag. ## 1.19.1 (2021-08-16) ### Bug fixes Fix another issue around copy-pasting table structure, causing inappropriate opening of copied cell selections. ## 1.19.0 (2021-08-13) ### Bug fixes Add a DOM attribute to the content element to avoid automatic translation services from messing with the editable text. Fix a bug where copy-pasting table content sometimes carried along superfluous table markup. Fix issue where end-of-textblock detection didn't use the correct selection when in a shadow root. ### New features The `DecorationSource` interface now exposes a `map` method. Add a translate=no attribute to the editor element by default ## 1.18.11 (2021-07-22) ### Bug fixes Work around an issue where Chrome and Safari will replace some spaces with non-breaking spaces when putting HTML on the clipboard. When pasting as plain text (shift-mod-v) apply the marks at the selection to the inserted content. Fix flaky behavior when starting a composition with a selection that spans multiple blocks. ## 1.18.10 (2021-07-15) ### Bug fixes Fix an issue where dragging from just outside a draggable node on Chrome would cause odd dragging behavior. ## 1.18.9 (2021-07-11) ### Bug fixes Fix a bug in the previous release where `handleClickOn` wasn't fired anymore for clicks with the middle or right mouse button. ## 1.18.8 (2021-06-23) ### Bug fixes Work around a Safari bug where it draws the cursor at the start of the line when it is after an uneditable node at the end of the line. Fix an issue where the DOM could get out of sync when editing decorated text. Work around an issue where Firefox draws the cursor on the wrong line when after a newline. Fix a bug where double-clicking with the left mouse button and then pressing another mouse button was treated as a triple click. ## 1.18.7 (2021-05-20) ### Bug fixes Fix a bug where clicking on a textblock that had a node-selected parent didn't set a cursor selection. Fix a bug that caused a workaround for a Chrome Android issue to not work correctly, leading to bad cursor placement after some types of text input. ## 1.18.6 (2021-05-17) ### Bug fixes Fix a crash in mouse click handling introduced in the previous version. ## 1.18.5 (2021-05-17) ### Bug fixes Work around a Firefox bug where backspace sometimes deletes the node after the cursor. Fix a bug that prevented `transformPasted` hooks from being called on content dragged within the editor. Fixes an issue where clicking near a node or other special selection on Chrome would in some cases do nothing. ## 1.18.4 (2021-04-27) ### Bug fixes Fix incorrect drag cursor in Chrome on some platforms. Fix an issue where a race condition could leave a node uneditable when clicked. Fix scroll handling when the editor is placed through a DOM component slot. Fix a typo in the Chrome backspace workaround. Fixes an issue where, when mouseup events weren't being delivered, the editor could leak event handlers. ## 1.18.3 (2021-04-13) ### Bug fixes Fix an issue where, when pressing enter or space at the start of a composition, the cursor would jump to the end of the composition on Chrome Android. Fix an issue that would cause Enter presses to be dropped on Android when in a node whose DOM representation nested more than one element. Fix a bug where pasting specific types of HTML could cause a crash. ## 1.18.2 (2021-03-25) ### Bug fixes Properly handle CSS class name strings with extra spaces in decorations. Fix a performance bug when updating nodes with thousands of children. ## 1.18.1 (2021-03-15) ### Bug fixes Fix the scrolling-into-view logic in the case where a scale transformation is applied to the editor. Strip carriage return chars from text pasted as code Remove carriage return characters when pasting text into code blocks. ## 1.18.0 (2021-03-04) ### Bug fixes Fix a crash in `posAtDOM`. ### New features Node view constructors and `update` methods are now passed the inner decorations of the node. ## 1.17.8 (2021-02-26) ### Bug fixes Fix an issue where some user actions (such as enter on iOS) in a node whose content DOM element isn't it's top element could leave the DOM in a damaged state. ## 1.17.7 (2021-02-22) ### Bug fixes Fix an issue where the `ProseMirror-hideselection` element class would be briefly removed and then restored when moving from one invisible selection to another. Fix an issue where the cursor could end up on the wrong side of a widget with `side` < 0. ## 1.17.6 (2021-02-11) ### Bug fixes Fix an issue where using the vertical arrow keys after select-all didn't update the selection. ## 1.17.5 (2021-02-05) ### Bug fixes Fix an issue where the view could go into an endless DOM flush loop in specific circumstances involving asynchronous DOM mutation. ## 1.17.4 (2021-02-04) ### Bug fixes Add another kludge to work around an issue where Firefox displays the cursor in the wrong place in code blocks. Fix a bug where validation of decorations passed to `DecorationSet.add` sometimes passed the wrong offsets to the validator. Fix bad selection position in empty textblocks. Solves several issues with editing in Firefox Android. ## 1.17.3 (2021-01-29) ### Bug fixes Fix a bug where adding invalid decorations (for example zero-length inline decorations) with `DecorationSet.add` would fail to drop those. ## 1.17.2 (2021-01-12) ### Bug fixes The library will now always let the browser perform its native pasting behavior when the clipboard data is empty and no paste handler handles the event. Fix a bug where `domAtPos` (and thus cursor placement) would pick positions inside uneditable DOM or atom nodes. ## 1.17.1 (2021-01-08) ### Bug fixes Fix a regression in `coordsAtPos` when used on an empty line at the end of a code block. ## 1.17.0 (2021-01-07) ### Bug fixes Fix an issue where starting a composition with stored marks would sometimes create the wrong steps (and thus break the mark) on Chrome. ### New features `EditorView.domAtPos` now takes a second parameter that can be used to control whether it should enter DOM nodes on the side of the given position. ## 1.16.5 (2020-12-11) ### Bug fixes Fix platform detection on recent iPadOS versions, restoring several workarounds for bugs that were accidentally turned off there. ## 1.16.4 (2020-12-02) ### Bug fixes Fix an issue where the cursor ended up in the wrong place when pressing enter in an empty heading on iOS. ## 1.16.3 (2020-11-23) ### Bug fixes Fix an issue where pressing enter at the start of a line in a code block would leave the visible cursor in the wrong place on Firefox. ## 1.16.2 (2020-11-18) ### Bug fixes Fix a bug where overlapping inline decorations would get drawn incorrectly (and even corrupt the drawing of unrelated content). ## 1.16.1 (2020-10-26) ### Bug fixes Fix an issue where the attributes of defining nodes were dropped when copying to the clipboard. ## 1.16.0 (2020-10-01) ### Bug fixes Fix an issue where a drag starting briefly after an aborted drag could confuse the view and break the second drag. Allow callers of coordsAtPos to specify a side ### New features `EditorView.coordsAtPos` now takes a `side` argument that determines which side of the position to look, if ambiguous. ## 1.15.7 (2020-09-11) ### Bug fixes Fix an issue where, when inserting `
` nodes, Safari would briefly show the cursor before the inserted break, though the DOM selection had already been set after it. When dragging inside the editor, whether the operation copies or moves is now determined by the modifiers held on drop, not on drag start. ## 1.15.6 (2020-09-03) ### Bug fixes Fix issue where the DOM selection could end up in an invalid state after a keyboard cursor motion event that had no effect. Fix an issue where some types of drop events would fail to select the dropped content. Work around Safari issues when pressing shift-down with the cursor before an uneditable element. ## 1.15.5 (2020-08-25) ### Bug fixes Fix an issue where mapping a decoration set could corrupt the decoration positions in specific cases. ## 1.15.4 (2020-08-13) ### Bug fixes Fix a crash that occurred when inline decorations covered inline nodes that weren't leaf nodes. ## 1.15.3 (2020-08-11) ### Bug fixes Work around a Firefox issue where the cursor is sometimes shown in the wrong place when directly after a `
` node. The editor will now reset composition when stored marks are set on the state, so that the marks can be added to the next input. Inline decorations are no longer applied to inline nodes that aren't leaves, only to the innermost layer. ## 1.15.2 (2020-07-09) ### Bug fixes Adjust the workaround for Chrome's DOM selection corruption bug to cover more cases. ## 1.15.1 (2020-07-09) ### Bug fixes Work around another issue where Chrome misreports the DOM selection. ## 1.15.0 (2020-06-24) ### Bug fixes Fix an issue where Enter on iOS might be handled twice on slow devices. Pass plain text flag to transformPastedText and clipboardTextParser props Fix a bug where typing in front of a mark could in some circumstances cause the editor to discard the new content. ### New features The `transformPastedText` and `clipboardTextParser` props now receive an extra argument, `plain`, indicating whether the paste was forced as plain text. ## 1.14.13 (2020-06-05) ### Bug fixes Fix a bug where storing DOM nodes directly in widget decorations (not recommended) could cause the view to try and place the same DOM node multiple times. ## 1.14.12 (2020-06-03) ### Bug fixes Fix a crash when the editor tries to read a DOM selection outside of itself. Improve the way inline decorations covering non-leaf inline nodes are rendered. Ensure elt is defined before accessing it in posAtCoords Fix a crash in Safari when the browser's `elementFromPoint` returns null in `posAtCoords`. Handle case where Chrome flips the nesting order of edited inline nodes Fix the issue of `` marks on decorated text being lost during editing because Chrome changes the nesting order of the link and the decoration `` element in the DOM. Fix an issue where, when pressing enter with a bolded virtual keyboard suggestion on Android's Gboard, the cursor would stay on the wrong line. ## 1.14.11 (2020-05-19) ### Bug fixes Fix bug in the way the editor handles Cmd-arrow presses on macOS. ## 1.14.10 (2020-05-18) ### Bug fixes Fix an issue where the editor would override behavior for Cmd-arrow key presses on macOS the wrong way in some situations. Fix handling of copy and paste in IE when top-level elements can't be focused. ## 1.14.9 (2020-05-06) ### Bug fixes Fix a crash on IE, which sets `document.activeElement` to null in some circumstances. ## 1.14.8 (2020-05-01) ### Bug fixes Work around an issue in Safari where you couldn't click inside a selected element to put the cursor there. Fix enter at start of paragraph in iOS inserting two new paragraphs. Scrolling the cursor into view now makes sure it doesn't end up below a scrollbar. ## 1.14.7 (2020-04-20) ### Bug fixes Fix a crash on Chrome during selection updates when `Selection.collapse` inexplicably leaves the selection empty. Update documented type for handlePaste event arg Fix another issue that could break decoration set mapping in deeply nested nodes. ## 1.14.6 (2020-03-25) ### Bug fixes Fix superfluous cursor showing up in Chrome when there is a gap cursor or similar custom empty selection active. Fix an issue where `DecorationSet.remove` would ignore the positions of its argument decorations, and only compare by type. ## 1.14.5 (2020-03-23) ### Bug fixes Work around Chrome Android issue where pasting would close the virtual keyboard. Fix an issue where some kinds of changes would cause nodes to show up twice in the DOM. ## 1.14.4 (2020-03-17) ### Bug fixes Improve return values from `coordsAtPos` on line breaks in Safari and Firefox. Make sure enter on iOS is handled even when the native behavior has no effect. ## 1.14.3 (2020-03-16) ### Bug fixes Fix mismatch between DOM and state selection bug at compositionend in IE11. Make sure `handleDrop`](https://prosemirror.net/docs/ref/#view.EditorProps.handleDrop) is called even when there's nothing on the clipboard. Fix a bug where reconfiguring a view in a way that changed both the active node views and the attributes of the top node left the old attributes active. Work around another case where Chrome lies to the script about its current DOM selection state. Avoid redrawing nodes when both their content and a widget in front of them is updated in the same transaction. Fix issue where scrolling with multiple scrollable containers sometimes moves to the wrong position. ## 1.14.2 (2020-02-10) ### Bug fixes Fix bug when starting a composition after a link, when the composition started with the character that ended the link. ## 1.14.1 (2020-02-08) ### Bug fixes Fix issue where scrolling the cursor into view in a scrollable editor would sometimes inappropriately scroll an outer container as well. ## 1.14.0 (2020-02-07) ### Bug fixes Fix parsing of `tbody`, `tfoot`, and `caption` elements in pasted HTML content. Fix bug in selection-is-at-edge check Fix an issue where moving focus to the editor with the keyboard or the DOM `focus` method would leave the DOM and state selections inconsistent. ### New features Widget decorations can now take an `ignoreSelection` option, that causes the editor to leave selections inside them alone. ## 1.13.11 (2020-01-31) ### Bug fixes Fix an issue that could lead to the editor making regular content uneditable on Safari. ## 1.13.10 (2020-01-29) ### Bug fixes Fix a crash on Firefox when starting a composition after a marked non-text node. ## 1.13.9 (2020-01-29) ### Bug fixes Make sure to reset the selection when the browser moves it into an uneditable node. Fix issue where the editor would fail to create a meaningful DOM selection for a node selection on Safari. Makes sure the iOS virtual keyboard gets its internal state (autocorrection, autocapitalization) updated when the user presses enter. ## 1.13.8 (2020-01-24) ### Bug fixes Fix bug that would sometimes cause widget decorations to be drawn with marks from the node after the text node they were inside of. ## 1.13.7 (2019-12-16) ### Bug fixes Fix a bug that caused the DOM to go out of sync with the decorations when updating inline decorations that added multiple wrapping nodes to a piece of content. ## 1.13.6 (2019-12-13) ### Bug fixes Fix a crash when deleting a list item in Safari while using a parse rule with a `context` property for `
  • ` elements. Work around another case where Chrome reports an incorrect selection. Work around issue where Firefox will insert a stray BR node when deleting a text node in some types of DOM structure. ## 1.13.5 (2019-12-09) ### Bug fixes Fix the way decorations update node styles to allow removing CSS custom properties. Link to https in readme and changelog The `root` accessor on views now makes sure that, when it returns a shadow root, that object has a `getSelection` method. Fix an issue where the DOM selection could get out of sync with ProseMirror's selection state in Edge. ## 1.13.4 (2019-11-20) ### Bug fixes Rename ES module files to use a .js extension, since Webpack gets confused by .mjs ## 1.13.3 (2019-11-19) ### Bug fixes Fix issue where the editor wouldn't update its internal selection when the editor was blurred, its selection was changed programatically, and then the editor was re-focused with its old DOM selection. The file referred to in the package's `module` field now is compiled down to ES5. ## 1.13.2 (2019-11-14) ### Bug fixes Fix issue where `EditorView.focus` would scroll the top of the document into view on Safari. ## 1.13.1 (2019-11-12) ### Bug fixes Work around selection jumping that sometimes occurs on Chrome when focusing the editor. ## 1.13.0 (2019-11-08) ### New features Add a `module` field to package json file. ## 1.12.3 (2019-11-07) ### Bug fixes Fix issue where paste events were stopped when the clipboard parser failed to make sense of the content. Fix issue where the `handlePaste` prop might be called multiple times for a single paste. ## 1.12.2 (2019-11-05) ### Bug fixes Set the editable element to use a `white-space: break-spaces` style so that whitespace at the end of a line properly moves the cursor to the next line. Fix issue where `posAtCoords` could throw an error in some circumstances on Firefox. Don't force focus back on the editor if a node view moves focus in its `setSelection` method. ## 1.12.1 (2019-10-28) ### Bug fixes Reduce unnecessary redraws when typing creates a new text node on Chrome. The default prosemirror.css now also turns off ligatures in Edge. Fix issue where the cursor stays before the typed text in Edge, when typing in an empty paragraph or between hard break nodes. ## 1.12.0 (2019-10-21) ### New features The mutation records passed to [`ignoreMutation`](https://prosemirror.net/docs/ref/#view.NodeView.ignoreMutation) now contain the old attribute value. ## 1.11.7 (2019-10-15) ### Bug fixes Enabling a mark and then starting a composition, on Chrome Android, will no longer cause the cursor to jump to the start of the composition. ## 1.11.6 (2019-10-07) ### Bug fixes Fix workaround for broken IE11 DOM change records when inserting between `
    ` nodes to handle more cases. ## 1.11.5 (2019-10-04) ### Bug fixes Don't leave DOM selection in place when it is inside a node view but not inside its content DOM element. ## 1.11.4 (2019-09-27) ### Bug fixes Fix an IE11 issue where marks would sometimes unexpectedly get dropped when inserting a space after marked text. Fixes an issue where `handleTextInput` wasn't called when typing over a single character with the same character. ## 1.11.3 (2019-09-20) ### Bug fixes Fix an issue where the DOM node representing a mark could be corrupted when the browser decides to replace it with another node but ProseMirror restored the old node after the change. Handle another case where typing over a selection in IE11 confused the editor. ## 1.11.2 (2019-09-17) ### Bug fixes Fix an issue where typing over a decorated piece of text would sometimes just act like deletion. Fix another problem in IE11 with typing over content, where typing over a decorated bit of text caused a crash. ## 1.11.1 (2019-09-16) ### Bug fixes Fix issue where typing over the entire contents of an inline node on IE11 would insert the typed content in the wrong position. ## 1.11.0 (2019-09-16) ### Bug fixes Fix an issue where IE11 would select the entire textblock after deleting content at its start. ### New features View instances now have a public `editable` property that indicates whether they are in editable mode. ## 1.10.3 (2019-09-04) ### Bug fixes Fix a regression in 1.10.2 that broke copying on IE11. ## 1.10.2 (2019-09-03) ### Bug fixes Fix an issue where `posAtCoords` could crash by dereferencing undefined in some circumstances. Fix inserting text next to a hard break in IE11. Fix an issue where typing over a selection would result in two different transactions (once for the deletion, once for the insertion) on IE11. Selecting the word at the start of the document and typing over it no longer causes the text input to appear at the end of the document in IE11. ## 1.10.1 (2019-08-28) ### Bug fixes Copying content will no longer create elements in the main document, which prevents images from loading just because they appear in clipboard content. ## 1.10.0 (2019-08-13) ### Bug fixes Fix an issue that caused the cursor to be scrolled into view when `focus()` was called on IE11. Fix problem where the cursor cycled through pieces of right-to-left text on Firefox during horizontal motion when the gapcursor plugin was enabled. Fix spurious mutation events in Firefox causing mark replacement at end of composition. Restore call to dom.focus on view.focus Fix a bug that could cause node views in front of marked nodes to not be destroyed when deleted, and caused confusion in composition handling in some situations. Cursor wrappers (a kludge to make sure typed text gets wrapping DOM structure corresponding to the current marks) are now created less eagerly, and in a less invasive way, which resolves a number of problems with composition (especially on Safari) and bidirectional text. ### New features Node views can now ignore selection change events through their [`ignoreMutation`](https://prosemirror.net/docs/ref/#view.NodeView.ignoreMutation) callback. ## 1.9.13 (2019-07-29) ### Bug fixes Fix an issue where copying content from a ProseMirror instance into an instance using another schema could, in some circumstances, insert schema-violating content. Fix comparison of decoration sets, which should solve unneccesary re-renders when updating decorations with an identical but newly allocated set. Don't update DOM selection in uneditable editors when the focus is elsewhere Fix a bug where the editor would steal focus from child elements when in non-editable mode. Fix error and corruption in IE11 when backspacing out a single character after a br node. ## 1.9.12 (2019-07-16) ### Bug fixes Fix a crash `posAtCoords` in Firefox when the coordinates are above a text input field. ## 1.9.11 (2019-07-03) ### Bug fixes Fix an issue where the DOM change handler would treat the parsed content as the wrong part of the document. Fix an issue in IE11 where deleting the last character in a textblock causes a crash. Fix an issue where backspacing out the first character in a textblock would cause IE11 to move the selection to some incorrect position. ## 1.9.10 (2019-06-12) ### Bug fixes Fix a crash in `coordsAtPos` caused by use of an incorrect variable name. ## 1.9.9 (2019-06-09) ### Bug fixes Fix arrowing over unselectable inline nodes in Chrome and Safari, which by default introduce an extra needless cursor position before the node. Fix a bug that caused DOM changes to be ignored when happening directly in front of some types of DOM events (such as focus/blur). ## 1.9.8 (2019-05-29) ### Bug fixes Fix an issue where moving focus from a node inside of the editor to the editor itself could sometimes lead to a node selection around the inner node rather than the intended selection (on Chrome). ## 1.9.7 (2019-05-28) ### Bug fixes ProseMirror will no longer try to stabilize the scroll position during updates on browsers that support [scroll anchoring](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor), since it'd inadvertently cancel the browser's behavior. Fix an issue in Safari where the editor would interrupt the composition spacebar menu because it incorrectly interpreted the mutation events fired by the browser as representing a replacement of the selection with identical text. Work around an issue where, on Safari, an IME composition started in an empty textblock would vanish when you press enter. ## 1.9.6 (2019-05-17) ### Bug fixes Fix bug in composition handling when the composition's parent node has an extra wrapper node around its content. ## 1.9.5 (2019-05-14) ### Bug fixes Fix regression in handling text editing events on IE11. ## 1.9.4 (2019-05-13) ### Bug fixes Fix a regression where all plugin views were recreated when calling [`setProps`](https://prosemirror.net/docs/ref/#view.EditorView.setProps). ## 1.9.3 (2019-05-10) ### Bug fixes Fix a bug where, if the document was changed at exactly the right moment, `handleClickOn` could be called with `null` as the node. ## 1.9.2 (2019-05-08) ### Bug fixes Fix a bug where updating to a reconfigured state would not recreate the view's plugin views. ## 1.9.1 (2019-05-04) ### Bug fixes Fix a regression where mouse selection would sometimes raise an error. ## 1.9.0 (2019-05-03) ### New features Changes made during compositions now immediately fire transactions on each update, rather than only a single one at the end of the composition. The view now immediately shows changes to the document or decorations during composition, even if they come from transactions not directly generated by the use's editing. The only exception is decorations that affect the focused text node—those are still delayed to avoid unneccesarily canceling the composition. ## 1.8.9 (2019-04-18) ### Bug fixes Improve display update times for nodes with thousands of children by fix an accidental piece of quadratic complexity. Fixes an issue where changes to the [`nodeViews` prop](https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews) weren't noticed when using [`updateState`](https://prosemirror.net/docs/ref/#view.EditorView.updateState) to update the view. Fix issue where sometimes moving the selection back its last position with the mouse failed to update ProseMirror's selection state. No longer call [`deselectNode`](https://prosemirror.net/docs/ref/#view.NodeView.deselectNode) on already-destroyed node views. ## 1.8.8 (2019-04-11) ### Bug fixes Fix a regression from 1.8.4 that made it return unreasonable rectangles for positions between blocks. ## 1.8.7 (2019-04-09) ### Bug fixes The [`handlePaste`](https://prosemirror.net/docs/ref/#view.EditorProps.handlePaste) prop is now activated even when the default parser can't make any sense of the clipboard content. ## 1.8.6 (2019-04-08) ### Bug fixes Fix a bug where decorations splitting a text node would sometimes confuse the display updater and make decorated nodes disappear. ## 1.8.5 (2019-04-08) ### Bug fixes Multiple [`transformPastedHTML`](https://prosemirror.net/docs/ref/#view.EditorProps.transformPastedHTML) props are now all properly called in order, rather than only the first one. Fixes an issue where invalid change positions were computed when a composition happened concurrently with a change that inserted content at the same position. ## 1.8.4 (2019-03-20) ### Bug fixes [`EditorView.coordsAtPos`](https://prosemirror.net/docs/ref/#view.EditorView.coordsAtPos) is now more accurate in right-to-left text on Chrome and Firefox. [`EditorView.coordsAtPos`](https://prosemirror.net/docs/ref/#view.EditorView.coordsAtPos) returns more accurate coordinates when querying the position directly after a line wrap point. Fix an issue where clicking directly in front of a node selection doesn't clear the node selection markup. ## 1.8.3 (2019-03-04) ### Bug fixes Fix an issue where clicking when there's a non-text selection active sometimes doesn't cause the appropriate new selection. ## 1.8.2 (2019-02-28) ### Bug fixes Fix an issue where a view state update happening between a change to the DOM selection and the corresponding browser event could disrupt mouse selection. ## 1.8.1 (2019-02-22) ### Bug fixes Fix infinite loop in `coordsAtPos`. ## 1.8.0 (2019-02-21) ### Bug fixes Fix a bug where [`endOfTextblock`](https://prosemirror.net/docs/ref/#view.EditorView.endOfTextblock) spuriously returns true when the cursor is in a mark. ### New features [`posAtCoords`](https://prosemirror.net/docs/ref/#view.EditorView.posAtCoords) will no longer return `null` when called with coordinates outside the browser's viewport. (It _will_ still return null for coordinates outside of the editor's bounding box.) ## 1.7.3 (2019-02-20) ### Bug fixes [`endOfTextblock`](https://prosemirror.net/docs/ref/#view.EditorView.endOfTextblock) now works on textblocks that are the editor's top-level node. ## 1.7.2 (2019-02-20) ### Bug fixes Pressing shift-left/right next to a selectable node no longer selects the node instead of creating a text selection across it. ## 1.7.1 (2019-02-04) ### Bug fixes Fix an issue on Safari where an Enter key events that was part of a composition is interpreted as stand-alone Enter press. ## 1.7.0 (2019-01-29) ### Bug fixes Fix an issue where node selections on uneditable nodes couldn't be copied or cut on Chrome. ### New features The editable view now recognizes the [`spanning`](https://prosemirror.net/docs/ref/#model.MarkSpec.spanning) mark property. ## 1.6.8 (2019-01-03) ### Bug fixes When replacing a selection by typing over it with a letter that matches its start or end, the editor now generates a step that covers the whole replacement. Fixes dragging a node when the mouse is in a child DOM element that doesn't represent a document node. Work around Chrome bug in selection management Fixes an issue in Chrome where clicking at the start of a textblock after a selected node would sometimes not move the cursor there. Fix issue where a node view's `getPos` callback could sometimes return `NaN`. Fix an issue where deleting more than 5 nodes might cause the nodes after that to be needlessly redrawn. ## 1.6.7 (2018-11-26) ### Bug fixes Avoids redrawing of content with marks when other content in front of it is deleted. ## 1.6.6 (2018-11-15) ### Bug fixes Work around a Chrome bug where programmatic changes near the cursor sometimes cause the visible and reported selection to disagree. Changing the `nodeView` prop will no longer leave outdated node views in the DOM. Work around an issue where Chrome unfocuses the editor or scrolls way down when pressing down arrow with the cursor between the start of a textblock and an uneditable element. Fix a bug where mapping decoration sets through changes that changed the structure of decorated subtrees sometimes produced corrupted output. ## 1.6.5 (2018-10-29) ### Bug fixes Work around Safari issue where deleting the last bit of text in a table cell creates weird HTML with a BR in a table row. ## 1.6.4 (2018-10-19) ### Bug fixes Fix pasting when both text and files are present on the clipboard. ## 1.6.3 (2018-10-12) ### Bug fixes The editor will no longer try to handle file paste events with the old-browser compatibility kludge (which might cause scrolling and focus flickering). ## 1.6.2 (2018-10-08) ### Bug fixes Fixes an issue where event handlers were leaked when destroying an editor ## 1.6.1 (2018-10-01) ### Bug fixes Fixes situation where a vertical [`endOfTextblock`](https://prosemirror.net/docs/ref/#view.EditorView.endOfTextblock) query could get confused by nearby widgets or complex parent node representation. ## 1.6.0 (2018-09-27) ### Bug fixes Fixes a corner case in which DecorationSet.map would map decorations to incorrect new positions. When the editor contains scrollable elements, scrolling the cursor into view also scrolls those. ### New features The `scrollMargin` and `scrollThreshold` props may now hold `{left, right, top, bottom}` objects to set different margins and thresholds for different sides. Make scrolling from a given start node more robust ## 1.5.3 (2018-09-24) ### Bug fixes The cursor is now scrolled into view after keyboard driven selection changes even when they were handled by the browser. ## 1.5.2 (2018-09-07) ### Bug fixes Improves selection management around widgets with no actual HTML content (possibly drawn using CSS pseudo elements). Fix extra whitespace in pasted HTML caused by previously-collapsed spacing. Slow triple-clicks are no longer treated as two double-clicks in a row. ## 1.5.1 (2018-08-24) ### Bug fixes Fix issue where some DOM selections would cause a non-editable view to crash when reading the selection. ## 1.5.0 (2018-08-21) ### New features Mark views are now passed a boolean that indicates whether the mark's content is inline as third argument. ## 1.4.4 (2018-08-13) ### Bug fixes Fix an issue where a non-empty DOM selection could stick around even though the state's selection is empty. Fix an issue where Firefox would create an extra cursor position when arrow-keying through a widget. ## 1.4.3 (2018-08-12) ### Bug fixes Fix an issue where the editor got stuck believing shift was down (and hence pasting as plain text) when it was unfocused with shift held down. ## 1.4.2 (2018-08-03) ### Bug fixes Fix an issue where reading the selection from the DOM might crash in non-editable mode. ## 1.4.1 (2018-08-02) ### Bug fixes Fixes an issue where backspacing out the last character between the start of a textblock and a widget in Chrome would insert a random hard break. ## 1.4.0 (2018-07-26) ### New features The `dispatchTransaction` prop is now called with `this` bound to the editor view. ## 1.3.8 (2018-07-24) ### Bug fixes Fix an issue where Chrome Android would move the cursor forward by one after backspace-joining two paragraphs. ## 1.3.7 (2018-07-02) ### Bug fixes Fix a crash when scrolling things into view when the editor isn't a child of `document.body`. ## 1.3.6 (2018-06-21) ### Bug fixes Make sure Safari version detection for clipboard support also works in iOS webview. ## 1.3.5 (2018-06-20) ### Bug fixes Use shared implementation of [`dropPoint`](https://prosemirror.net/docs/ref/#transform.dropPoint) to handle finding a drop position. ## 1.3.4 (2018-06-20) ### Bug fixes Enable use of browser clipboard API on Mobile Safari version 11 and up, which makes cut work on that platform and should generally improve clipboard handling. ## 1.3.3 (2018-06-15) ### Bug fixes Fix arrow-left cursor motion from cursor wrapper (for example after a link). Fix selection glitches when shift-selecting around widget decorations. Fix issue where a parsing a code block from the editor DOM might drop newlines in the code. ## 1.3.2 (2018-06-15) ### Bug fixes [`handleKeyDown`](https://prosemirror.net/docs/ref/#view.EditorProps.handleKeyDown) will now get notified of key events happening directly after a composition ends. ## 1.3.1 (2018-06-08) ### Bug fixes The package can now be loaded in a web worker context (where `navigator` is defined but `document` isn't) without crashing. Dropping something like a list item into a textblock will no longer split the textblock. ## 1.3.0 (2018-04-24) ### Bug fixes Fix mouse-selecting (in IE and Edge) from the end of links and other positions that cause a cursor wrapper. [Widget decorations](https://prosemirror.net/docs/ref/#view.Decoration^widget) with the same [key](https://prosemirror.net/docs/ref/#view.Decoration^widget^spec.key) are now considered equivalent, even if their other spec fields differ. ### New features The new [`EditorView.posAtDOM` method](https://prosemirror.net/docs/ref/#view.EditorView.posAtDOM) can be used to find the document position corresponding to a given DOM position. The new [`EditorView.nodeDOM` method](https://prosemirror.net/docs/ref/#view.EditorView.nodeDOM) gives you the DOM node that is used to represent a specific node in the document. [`Decoration.widget`](https://prosemirror.net/docs/ref/#view.Decoration^widget) now accepts a function as second argument, which can be used to delay rendering of the widget until the document is drawn (at which point a reference to the view is available). The `getPos` function passed to a [node view constructor](https://prosemirror.net/docs/ref/#view.editorProps.nodeViews) can now be called immediately (it used to return undefined until rendering had finished). The function used to render a [widget](https://prosemirror.net/docs/ref/#view.Decoration^widget) is now passed a `getPos` method that event handlers can use to figure out where in the DOM the widget is. ## 1.2.0 (2018-03-14) ### Bug fixes Fix a problem where updating the state of a non-editable view would not set the selection, causing problems when the DOM was updated in a way that disrupted the DOM selection. Fix an issue where, on IE and Chrome, starting a drag selection in a position that required a cursor wrapper (on a mark boundary) would sometimes fail to work. Fix crash in key handling when the editor is focused but there is no DOM selection. Fixes a bug that prevented decorations inside node views with a [`contentDOM` property](https://prosemirror.net/docs/ref/#view.NodeView.contentDOM) from being drawn. Fixes an issue where, on Firefox, depending on a race condition, the skipping over insignificant DOM nodes done at keypress was canceled again before the keypress took effect. Fixes an issue where an `:after` pseudo-element on a non-inclusive mark could block the cursor, making it impossible to arrow past it. ### New features The DOM structure for marks is no longer constrained to a single node. [Mark views](https://prosemirror.net/docs/ref/#view.NodeView) can have a `contentDOM` property, and [mark spec](https://prosemirror.net/docs/ref/#model.MarkSpec) `toDOM` methods can return structures with holes. [Widget decorations](https://prosemirror.net/docs/ref/#view.Decoration^widget) are now wrapped in the marks of the node after them when their [`side` option](https://prosemirror.net/docs/ref/#view.Decoration^widget^spec.side) is >= 0. [Widget decorations](https://prosemirror.net/docs/ref/#view.Decoration^widget) may now specify a [`marks` option](https://prosemirror.net/docs/ref/#view.Decoration^widget^spec.marks) to set the precise set of marks they should be wrapped in. ## 1.1.1 (2018-03-01) ### Bug fixes Fixes typo that broke paste. ## 1.1.0 (2018-02-28) ### Bug fixes Fixes issue where dragging a draggable node directly below a selected node would move the old selection rather than the target node. A drop that can't fit the dropped content will no longer dispatch an empty transaction. ### New features Transactions generated for drop now have a `"uiEvent"` metadata field holding `"drop"`. Paste and cut transactions get that field set to `"paste"` or `"cut"`. ## 1.0.11 (2018-02-16) ### Bug fixes Fix issue where the cursor was visible when a node was selected on recent Chrome versions. ## 1.0.10 (2018-01-24) ### Bug fixes Improve preservation of open and closed nodes in slices taken from the clipboard. ## 1.0.9 (2018-01-17) ### Bug fixes Work around a Chrome cursor motion bug by making sure
    nodes don't get a contenteditable=false attribute. ## 1.0.8 (2018-01-09) ### Bug fixes Fix issue where [`Decoration.map`](https://prosemirror.net/docs/ref/#view.DecorationSet.map) would in some situations with nested nodes incorrectly map decoration positions. ## 1.0.7 (2018-01-05) ### Bug fixes Pasting from an external source no longer opens isolating nodes like table cells. ## 1.0.6 (2017-12-26) ### Bug fixes [`DecorationSet.remove`](https://prosemirror.net/docs/ref/#view.DecorationSet.remove) now uses a proper deep compare to determine if widgets are the same (it used to compare by identity). ## 1.0.5 (2017-12-05) ### Bug fixes Fix an issue where deeply nested decorations were mapped incorrectly in corner cases. ## 1.0.4 (2017-11-27) ### Bug fixes Fix a corner-case crash during drop. ## 1.0.3 (2017-11-23) ### Bug fixes Pressing backspace between two identical characters will no longer generate a transaction that deletes the second one. ## 1.0.2 (2017-11-20) ### Bug fixes Fix test for whether a node can be selected when arrowing onto it from the right. Calling [`posAtCoords`](https://prosemirror.net/docs/ref/#view.EditorView.posAtCoords) while a read from the DOM is pending will no longer return a malformed result. ## 1.0.1 (2017-11-10) ### Bug fixes Deleting the last character in a list item no longer results in a spurious hard_break node on Safari. Fixes a crash on IE11 when starting to drag. ## 1.0.0 (2017-10-13) ### Bug fixes Dragging nodes with a node view that handles its own mouse events should work better now. List item DOM nodes are no longer assigned `pointer-events: none` in the default style. Ctrl-clicking list markers now properly selects the list item again. Arrow-down through an empty textblock no longer causes the browser to forget the cursor's horizontal position. Copy-dragging on OS X is now done by holding option, rather than control, following the convention on that system. Fixes a crash related to decoration management. Fixes a problem where using cut on IE11 wouldn't actually remove the selected text. Copy/paste on Edge 15 and up now uses the clipboard API, fixing a problem that made them fail entirely. ### New features The [`dragging`](https://prosemirror.net/docs/ref/#view.EditorView.dragging) property of a view, which contains information about editor content being dragged, is now part of the public interface. ## 0.24.0 (2017-09-25) ### New features The [`clipboardTextParser`](https://prosemirror.net/docs/ref/version/0.24.0.html#view.EditorProps.clipboardTextParser) prop is now passed a context position. ## 0.23.0 (2017-09-13) ### Breaking changes The `onFocus`, `onBlur`, and `handleContextMenu` props are no longer supported. You can achieve their effect with the [`handleDOMEvents`](https://prosemirror.net/docs/ref/version/0.23.0.html#view.EditorProps.handleDOMEvents) prop. ### Bug fixes Fixes occasional crash when reading the selection in Firefox. Putting a table cell on the clipboard now properly wraps it in a table. The view will no longer scroll into view when receiving a state that isn't derived from its previous state. ### New features Transactions caused by a paste now have their "paste" meta property set to true. Adds a new view prop, [`handleScrollToSelection`](https://prosemirror.net/docs/ref/version/0.23.0.html#view.EditorProps.handleScrollToSelection) to override the behavior of scrolling the selection into view. The new editor prop [`clipboardTextSerializer`](https://prosemirror.net/docs/ref/version/0.23.0.html#view.EditorProps.clipboardTextSerializer) allows you to override the way a piece of document is converted to clipboard text. Adds the editor prop [`clipboardTextParser`](https://prosemirror.net/docs/ref/version/0.23.0.html#view.EditorProps.clipboardTextParser), which can be used to define your own parsing strategy for clipboard text content. [`DecorationSet.find`](https://prosemirror.net/docs/ref/version/0.23.0.html#view.DecorationSet.find) now supports passing a predicate to filter decorations by spec. ## 0.22.1 (2017-08-16) ### Bug fixes Invisible selections that don't cover any content (i.e., a cursor) are now properly hidden. Initializing the editor view non-editable no longer causes a crash. ## 0.22.0 (2017-06-29) ### Bug fixes Fix an issue where moving the cursor through a text widget causes the editor to lose the selection in Chrome. Fixes an issue where down-arrow in front of a widget would sometimes not cause any cursor motion on Chrome. [Destroying](https://prosemirror.net/docs/ref/version/0.22.0.html#view.EditorView.destroy) a [mounted](https://prosemirror.net/docs/ref/version/0.22.0.html#view.EditorView.constructor) editor view no longer leaks event handlers. Display updates for regular, non-composition input are now synchronous, which should reduce flickering when, for example, updating decorations in response to typing. ### New features The editor can now be initialized in a document other than the global document (say, an `iframe`). Editor views now have a [`domAtPos` method](https://prosemirror.net/docs/ref/version/0.22.0.html#view.EditorView.domAtPos), which gives you the DOM position corresponding to a given document position. ## 0.21.1 (2017-05-09) ### Bug fixes Copying and pasting table cells on Edge no longer strips the table structure. ## 0.21.0 (2017-05-03) ### Breaking changes The `associative` option to widget decorations is no longer supported. To make a widget left-associative, set its `side` option to a negative number. `associative` will continue to work with a warning until the next release. ### New features [Widget decorations](https://prosemirror.net/docs/ref/version/0.21.0.html#view.Decoration^widget) now support a `side` option that controls which side of them the cursor is drawn, where they move when content is inserted at their position, and the order in which they appear relative to other widgets at the same position. ## 0.20.5 (2017-05-02) ### Bug fixes Fixes an issue where the DOM selection could be shown on the wrong side of hard break or image nodes. ## 0.20.4 (2017-04-24) ### Bug fixes Fix a bug that prevented the DOM selection from being updated when the new position was near the old one in some circumstances. Stop interfering with alt-d keypresses on OS X. Fix issue where reading a DOM change in a previously empty node could crash. Fixes crash when reading a change that removed a decorated text node from the DOM. ## 0.20.3 (2017-04-12) ### Bug fixes Shift-pasting and pasting into a code block now does the right thing on IE and Edge. ## 0.20.2 (2017-04-05) ### Bug fixes Fixes a bug that broke dragging from the editor. ## 0.20.1 (2017-04-04) ### Bug fixes Typing in code blocks no longer replaces newlines with spaces. Copy and paste on Internet Explorer, Edge, and mobile Safari should now behave more like it does on other browsers. Handlers are called, and the changes to the document are made by ProseMirror's code, not the browser. Fixes a problem where triple-clicking the editor would sometimes cause the scroll position to inexplicably jump around on IE11. ## 0.20.0 (2017-04-03) ### Breaking changes The `inclusiveLeft` and `inclusiveRight` options to inline decorations were renamed to [`inclusiveStart`](https://prosemirror.net/docs/ref/version/0.20.0.html#view.Decoration^inline^spec.inclusiveStart) and [`inclusiveEnd`](https://prosemirror.net/docs/ref/version/0.20.0.html#view.Decoration^inline^spec.inclusiveEnd) so that they also make sense in right-to-left text. The old names work with a warning until the next release. The default styling for lists and blockquotes was removed from `prosemirror.css`. (They were moved to the [`example-setup`](https://code.haverbeke.berlin/prosemirror/prosemirror-example-setup) module.) ### Bug fixes Fixes reading of selection in Chrome in a shadow DOM. Registering DOM event handlers that the editor doesn't listen to by default with the `handleDOMEvents` prop should work again. Backspacing after turning off a mark now works again in Firefox. ### New features The new props [`handlePaste`](https://prosemirror.net/docs/ref/version/0.20.0.html#view.EditorProps.handlePaste) and [`handleDrop`](https://prosemirror.net/docs/ref/version/0.20.0.html#view.EditorProps.handleDrop) can be used to override drop and paste behavior. ## 0.19.1 (2017-03-18) ### Bug fixes Fixes a number of issues with characters being duplicated or disappearing when typing on mark boundaries. ## 0.19.0 (2017-03-16) ### Breaking changes [`endOfTextblock`](https://prosemirror.net/docs/ref/version/0.19.0.html#view.EditorView.endOfTextblock) no longer always returns false for horizontal motion on non-cursor selections, but checks the position of the selection head instead. ### Bug fixes Typing after adding/removing a mark no longer briefly shows the new text with the wrong marks. [`posAtCoords`](https://prosemirror.net/docs/ref/version/0.19.0.html#view.EditorView.posAtCoords) is now more reliable on modern browsers by using browser APIs. Fix a bug where the view would in some circumstances leave superfluous DOM nodes around inside marks. ### New features You can now override the selection the editor creates for a given DOM selection with the [`createSelectionBetween`](https://prosemirror.net/docs/ref/version/0.19.0.html#view.EditorProps.createSelectionBetween) prop. ## 0.18.0 (2017-02-24) ### Breaking changes `Decoration` objects now store their definition object under [`spec`](https://prosemirror.net/docs/ref/version/0.18.0.html#Decoration.spec), not `options`. The old property name still works, with a warning, until the next release. ### Bug fixes Fix bug where calling [`focus`](https://prosemirror.net/docs/ref/version/0.18.0.html#view.EditorView.focus) when there was a text selection would sometimes result in `state.selection` receiving an incorrect value. [`EditorView.props`](https://prosemirror.net/docs/ref/version/0.18.0.html#view.EditorView.props) now has its `state` property updated when you call `updateState`. Putting decorations on or inside a node view with an `update` method now works. ### New features [Plugin view](https://prosemirror.net/docs/ref/version/0.18.0.html#state.PluginSpec.view) update methods are now passed the view's previous state as second argument. The `place` agument to the [`EditorView` constructor](https://prosemirror.net/docs/ref/version/0.18.0.html#view.EditorView) can now be an object with a `mount` property to directly provide the node that should be made editable. The new [`EditorView.setProps` method](https://prosemirror.net/docs/ref/version/0.18.0.html#view.EditorView.setProps) makes it easier to update individual props. ## 0.17.7 (2017-02-08) ### Bug fixes Fixes crash in the code that maintains the scroll position when the document is empty or hidden. ## 0.17.6 (2017-02-08) ### Bug fixes Transactions that shouldn't [scroll the selection into view](https://prosemirror.net/docs/ref/version/0.17.0.html#state.transaction.scrollIntoView) now no longer do so. ## 0.17.4 (2017-02-02) ### Bug fixes Fixes bug where widget decorations would sometimes get parsed as content when editing near them. The editor now prevents the behavior of Ctrl-d and Ctrl-h on textblock boundaries on OS X, as intended. Make sure long words don't cause a horizontal scrollbar in Firefox Various behavior fixes for IE11. ## 0.17.3 (2017-01-19) ### Bug fixes DOM changes deleting a node's inner wrapping DOM element (for example the `` tag in a schema-basic code block) no longer break the editor. ## 0.17.2 (2017-01-16) ### Bug fixes Call custom click handlers before applying select-node behavior for a ctrl/cmd-click. Fix failure to apply DOM changes that start at document position 0. ## 0.17.1 (2017-01-07) ### Bug fixes Fix issue where a document update that left the selection in the same place sometimes led to an incorrect DOM selection. Make sure [`EditorView.focus`](https://prosemirror.net/docs/ref/version/0.17.0.html#view.EditorView.focus) doesn't cause the browser to scroll the top of the editor into view. ## 0.17.0 (2017-01-05) ### Breaking changes The `handleDOMEvent` prop has been dropped in favor of the [`handleDOMEvents`](https://prosemirror.net/docs/ref/version/0.17.0.html#view.EditorProps.handleDOMEvents) (plural) prop. The `onChange` prop has been replaced by a [`dispatchTransaction`](https://prosemirror.net/docs/ref/version/0.17.0.html#view.EditorProps.dispatchTransaction) prop (which takes a transaction instead of an action). ### New features Added support for a [`handleDOMEvents` prop](https://prosemirror.net/docs/ref/version/0.17.0.html#view.EditorProps.handleDOMEvents), which allows you to provide handler functions per DOM event, and works even for events that the editor doesn't normally add a handler for. Add view method [`dispatch`](https://prosemirror.net/docs/ref/version/0.17.0.html#view.EditorView.dispatch), which provides a convenient way to dispatch transactions. The [`dispatchTransaction`](https://prosemirror.net/docs/ref/version/0.17.0.html#view.EditorProps.dispatchTransaction) (used to be `onAction`) prop is now optional, and will default to simply applying the transaction to the current view state. [Widget decorations](https://prosemirror.net/docs/ref/version/0.17.0.html#view.Decoration.widget) now accept an option `associative` which can be used to configure on which side of content inserted at their position they end up. Typing immediately after deleting text now preserves the marks of the deleted text. Transactions that update the selection because of mouse or touch input now get a metadata property `pointer` with the value `true`. ## 0.16.0 (2016-12-23) ### Breaking changes The `spellcheck`, `label`, and `class` props are now replaced by an [`attributes` prop](https://prosemirror.net/docs/ref/version/0.16.0.html#view.EditorProps.attributes). ### Bug fixes Solve problem where setting a node selection would trigger a DOM read, leading to the selection being reset. Ignoring/aborting an action should no longer lead to the DOM being stuck in an outdated state. Typing at the end of a textblock which ends in a non-text node now actually works. DOM nodes for leaf document nodes are now set as non-editable to prevent various issues such as stray cursors inside of them and Firefox adding image resize controls. Inserting a node no longer causes nodes of the same type after it to be neednessly redrawn. ### New features Add a new editor prop [`editable`](https://prosemirror.net/docs/ref/version/0.16.0.html#view.EditorProps.editable) which controls whether the editor's `contentEditable` behavior is enabled. Plugins and props can now set any DOM attribute on the outer editor node using the [`attributes` prop](https://prosemirror.net/docs/ref/version/0.16.0.html#view.EditorProps.attributes). Node view constructors and update methods now have access to the node's wrapping decorations, which can be used to pass information to a node view without encoding it in the document. Attributes added or removed by node and inline [decorations](https://prosemirror.net/docs/ref/version/0.16.0.html#view.Decoration) no longer cause the nodes inside of them to be fully redrawn, making node views more stable and allowing CSS transitions to be used. ## 0.15.2 (2016-12-10) ### Bug fixes The native selection is now appropriately hidden when there is a node selection. ## 0.15.1 (2016-12-10) ### Bug fixes Fix DOM parsing for decorated text nodes. ## 0.15.0 (2016-12-10) ### Breaking changes The editor view no longer wraps its editable DOM element in a wrapper element. The `ProseMirror` CSS class now applies directly to the editable element. The `ProseMirror-content` CSS class is still present for ease of upgrading but will be dropped in the next release. The editor view no longer draws a drop cursor when dragging content over the editor. The new [`prosemirror-dropcursor`](https://code.haverbeke.berlin/prosemirror/prosemirror-dropcursor) module implements this as a plugin. ### Bug fixes Simple typing and backspacing now gets handled by the browser without ProseMirror redrawing the touched nodes, making spell-checking and various platform-specific input tricks (long-press on OS X, double space on iOS) work in the editor. Improve tracking of DOM nodes that have been touched by user changes, so that [`updateState`](https://prosemirror.net/docs/ref/version/0.15.0.html#view.EditorView.updateState) can reliably fix them. Changes to the document that happen while dragging editor content no longer break moving of the content. Adding or removing a mark directly in the DOM (for example with the bold/italic buttons in iOS' context menu) now produces mark steps, rather than replace steps. Pressing backspace at the start of a paragraph on Android now allows key handlers for backspace to fire. Toggling a mark when there is no selection now works better on mobile platforms. ### New features Introduces an [`endOfTextblock`](https://prosemirror.net/docs/ref/version/0.15.0.html#view.EditorView.endOfTextblock) method on views, which can be used to find out in a bidi- and layout-aware way whether the selection is on the edge of a textblock. ## 0.14.4 (2016-12-02) ### Bug fixes Fix issue where node decorations would stick around in the DOM after the decoration was removed. Setting or removing a node selection in an unfocused editor now properly updates the DOM to show that selection. ## 0.14.2 (2016-11-30) ### Bug fixes FIX: Avoid unneeded selection resets which sometimes confused browsers. ## 0.14.2 (2016-11-29) ### Bug fixes Fix a bug where inverted selections weren't created in the DOM correctly. ## 0.14.1 (2016-11-29) ### Bug fixes Restores previously broken kludge that allows the cursor to appear after non-text content at the end of a line. ## 0.14.0 (2016-11-28) ### Breaking changes Wrapping decorations are now created using the [`nodeName`](https://prosemirror.net/docs/ref/version/0.14.0.html#view.DecorationAttrs.nodeName) property. The `wrapper` property is no longer supported. The `onUnmountDOM` prop is no longer supported (use a node view with a [`destroy`](https://prosemirror.net/docs/ref/version/0.14.0.html#view.NodeView.destroy) method instead). The `domSerializer` prop is no longer supported. Use [node views](https://prosemirror.net/docs/ref/version/0.14.0.html#view.EditorProps.nodeViews) to configure editor-specific node representations. ### New features Widget decorations can now be given a [`key`](https://prosemirror.net/docs/ref/version/0.14.0.html#view.Decoration.widget^options.key) property to prevent unneccesary redraws. The `EditorView` class now has a [`destroy`](https://prosemirror.net/docs/ref/version/0.14.0.html#view.EditorView.destroy) method for cleaning up. The [`handleClickOn`](https://prosemirror.net/docs/ref/version/0.14.0.html#view.EditorProps.handleClickOn) prop and friends now receive a `direct` boolean argument that indicates whether the node was clicked directly. [Widget decorations](https://prosemirror.net/docs/ref/version/0.14.0.html#view.Decoration^widget) now support a `stopEvent` option that can be used to control which DOM events that pass through them should be ignored by the editor view. You can now [specify](https://prosemirror.net/docs/ref/version/0.14.0.html#view.EditorProps.nodeViews) custom [node views](https://prosemirror.net/docs/ref/version/0.14.0.html#view.NodeView) for an editor view, which give you control over the way node of a given type are represented in the DOM. See the related [RFC](https://discuss.prosemirror.net/t/rfc-node-views-to-manage-the-representation-of-nodes/463). ## 0.13.2 (2016-11-15) ### Bug fixes Fixes an issue where widget decorations in the middle of text nodes would sometimes disappear. ## 0.13.1 (2016-11-15) ### Bug fixes Fixes event handler crash (and subsequent bad default behavior) when pasting some types of external HTML into an editor. ## 0.13.0 (2016-11-11) ### Breaking changes Selecting nodes on OS X is now done with cmd-leftclick rather than ctrl-leftclick. ### Bug fixes Pasting text into a code block will now insert the raw text. Widget decorations at the start or end of a textblock no longer block horizontal cursor motion through them. Widget nodes at the end of textblocks are now reliably drawn during display updates. ### New features [`DecorationSet.map`](https://prosemirror.net/docs/ref/version/0.13.0.html#view.DecorationSet.map) now takes an options object which allows you to specify an `onRemove` callback to be notified when remapping drops decorations. The [`transformPastedHTML`](https://prosemirror.net/docs/ref/version/0.13.0.html#view.EditorProps.transformPastedHTML) and [`transformPastedText`](https://prosemirror.net/docs/ref/version/0.13.0.html#view.EditorProps.transformPastedText) props were (re-)added, and can be used to clean up pasted content. ## 0.12.2 (2016-11-02) ### Bug fixes Inline decorations that span across an empty textblock no longer crash the display drawing code. ## 0.12.1 (2016-11-01) ### Bug fixes Use a separate document to parse pasted HTML to better protect against cross-site scripting attacks. Specifying multiple classes in a decoration now actually works. Ignore empty inline decorations when building a decoration set. ## 0.12.0 (2016-10-21) ### Breaking changes The return value of [`EditorView.posAtCoords`](https://prosemirror.net/docs/ref/version/0.12.0.html#view.EditorView.posAtCoords) changed to contain an `inside` property pointing at the innermost node that the coordinates are inside of. (Note that the docs for this method were wrong in the previous release.) ### Bug fixes Reduce reliance on shift-state tracking to minimize damage when it gets out of sync. Fix bug that'd produce bogus document positions for DOM positions inside non-document nodes. Don't treat fast ctrl-clicks as double or triple clicks. ### New features Implement [decorations](https://prosemirror.net/docs/ref/version/0.12.0.html#view.Decoration), a way to influence the way the document is drawn. Add the [`decorations` prop](https://prosemirror.net/docs/ref/version/0.12.0.html#view.EditorProps.decorations) to specify them. ## 0.11.2 (2016-10-04) ### Bug fixes Pass actual event object to [`handleDOMEvent`](https://prosemirror.net/docs/ref/version/0.11.0.html#view.EditorProps.handleDOMEvent), rather than just its name. Fix display corruption caused by using the wrong state as previous version during IME. ## 0.11.0 (2016-09-21) ### Breaking changes Moved into a separate module from the old `edit` submodule. Completely new approach to managing the editor's DOM representation and input. Event handlers and options are now replaced by [props](https://prosemirror.net/docs/ref/version/0.11.0.html#view.EditorProps). The view's state is now 'shallow', represented entirely by a set of props, one of which holds an editor state value from the [state](https://prosemirror.net/docs/ref/version/0.11.0.html#state) module. When the user interacts with the editor, it will pass an [action](https://prosemirror.net/docs/ref/version/0.11.0.html#state.Action) to its [`onAction`](https://prosemirror.net/docs/ref/version/0.11.0.html#view.EditorProps.onAction) prop, which is responsible for triggering an view update. The `markRange` system was dropped, to be replaced in the next release by a 'decoration' system. There is no keymap support in the view module anymore. Use a [keymap](https://prosemirror.net/docs/ref/version/0.11.0.html#keymap) plugin for that. The undo [history](https://prosemirror.net/docs/ref/version/0.11.0.html#history) is now a separate plugin. CSS needed by the editor is no longer injected implicitly into the page. Instead, you should arrange for the `style/prosemirror.css` file to be loaded into your page. ### New features The DOM [parser](https://prosemirror.net/docs/ref/version/0.11.0.html#model.DOMParser) and [serializer](https://prosemirror.net/docs/ref/version/0.11.0.html#model.DOMSerializer) used to interact with the visible DOM and the clipboard can now be customized through [props](https://prosemirror.net/docs/ref/version/0.11.0.html#view.EditorProps). You can now provide a catch-all DOM [event handler](https://prosemirror.net/docs/ref/version/0.11.0.html#view.EditorProps.handleDOMEvent) to get a first chance at handling DOM events. The [`onUnmountDOM`](https://prosemirror.net/docs/ref/version/0.11.0.html#view.EditorProps.onUnmountDOM) can be used to be notified when a piece of the document DOM is thrown away (in case cleanup is needed). prosemirror-view/CONTRIBUTING.md000066400000000000000000000073301527132543100166500ustar00rootroot00000000000000# How to contribute - [Getting help](#getting-help) - [Submitting bug reports](#submitting-bug-reports) - [Contributing code](#contributing-code) ## Getting help Community discussion, questions, and informal bug reporting is done on the [discuss.ProseMirror forum](http://discuss.prosemirror.net). ## Submitting bug reports Report bugs on the [issue tracker](https://code.haverbeke.berlin/prosemirror/prosemirror/issues). Before reporting a bug, please read these pointers. - The issue tracker is for *bugs*, not requests for help. Questions should be asked on the [forum](http://discuss.prosemirror.net). - Include information about the version of the code that exhibits the problem. For browser-related issues, include the browser and browser version on which the problem occurred. - Mention very precisely what went wrong. "X is broken" is not a good bug report. What did you expect to happen? What happened instead? Describe the exact steps a maintainer has to take to make the problem occur. A screencast can be useful, but is no substitute for a textual description. - A great way to make it easy to reproduce your problem, if it can not be trivially reproduced on the website demos, is to submit a script that triggers the issue. ## Contributing code Code generated by a language model is not welcome in this project. Please don't waste my time with it. If you want to make a change that involves a significant overhaul of the code or introduces a user-visible new feature, create an [issue](https://code.haverbeke.berlin/prosemirror/prosemirror/issues/) first with your proposal. - Make sure you have a [Codeberg](https://codeberg.org/user/sign_up) or [GitHub](https://github.com/signup/free) account. - Use that to create a [code.haverbeke.berlin account](https://code.haverbeke.berlin/user/login). - Fork the relevant repository. - Create a local checkout of the code. You can use the [main repository](https://code.haverbeke.berlin/prosemirror/prosemirror) to easily check out all core modules. - Make your changes, and commit them - If your changes are easy to test or likely to regress, add tests in the relevant `test/` directory. Either put them in an existing `test-*.js` file, if they fit there, or add a new file. - Make sure all tests pass. Run `npm run test` to verify tests pass (you will need Node.js v6+). - Submit a pull request. Don't put more than one feature/fix in a single pull request. By contributing code to ProseMirror you - Agree to license the contributed code under the project's [MIT license](https://code.haverbeke.berlin/prosemirror/prosemirror/blob/main/LICENSE). - Confirm that you have the right to contribute and license the code in question. (Either you hold all rights on the code, or the rights holder has explicitly granted the right to use it like this, through a compatible open source license or through a direct agreement with you.) ### Coding standards - ES6 syntax, targeting an ES5 runtime (i.e. don't use library elements added by ES6, don't use ES7/ES.next syntax). - 2 spaces per indentation level, no tabs. - No semicolons except when necessary. - Follow the surrounding code when it comes to spacing, brace placement, etc. - Brace-less single-statement bodies are encouraged whenever they don't impact readability. - [getdocs-ts](https://code.haverbeke.berlin/marijn/getdocs-ts)-style doc comments above items that are part of the public API. - When documenting non-public items, you can put the type after a single colon, so that getdocs doesn't pick it up and add it to the API reference. - ProseMirror does *not* follow JSHint or JSLint prescribed style. Patches that try to 'fix' code to pass one of these linters will not be accepted. prosemirror-view/LICENSE000066400000000000000000000021211527132543100154150ustar00rootroot00000000000000Copyright (C) 2015-2017 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. prosemirror-view/README.md000066400000000000000000000026221527132543100156750ustar00rootroot00000000000000# prosemirror-view [ [**WEBSITE**](https://prosemirror.net) | [**ISSUES**](https://code.haverbeke.berlin/prosemirror/prosemirror/issues) | [**FORUM**](https://discuss.prosemirror.net) | [**CHANGELOG**](https://code.haverbeke.berlin/prosemirror/prosemirror-view/src/branch/main/CHANGELOG.md) ] This is a [core module](https://prosemirror.net/docs/ref/#view) of [ProseMirror](https://prosemirror.net). ProseMirror is a well-behaved rich semantic content editor based on contentEditable, with support for collaborative editing and custom document schemas. This [module](https://prosemirror.net/docs/ref/#view) exports the editor view, which renders the current document in the browser, and handles user events. The [project page](https://prosemirror.net) has more information, a number of [examples](https://prosemirror.net/examples/) and the [documentation](https://prosemirror.net/docs/). This code is released under an [MIT license](https://code.haverbeke.berlin/prosemirror/prosemirror/src/branch/main/LICENSE). There's a [forum](http://discuss.prosemirror.net) for general discussion and support requests, and the [bug tracker](https://code.haverbeke.berlin/prosemirror/prosemirror/issues) is the place to report issues. 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. prosemirror-view/package.json000066400000000000000000000021051527132543100167000ustar00rootroot00000000000000{ "name": "prosemirror-view", "version": "1.42.9", "description": "ProseMirror's view component", "type": "module", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" }, "./style/prosemirror.css": "./style/prosemirror.css" }, "sideEffects": ["./style/prosemirror.css"], "style": "style/prosemirror.css", "license": "MIT", "maintainers": [ { "name": "Marijn Haverbeke", "email": "marijn@haverbeke.berlin", "web": "http://marijnhaverbeke.nl" } ], "repository": { "type": "git", "url": "git+https://code.haverbeke.berlin/prosemirror/prosemirror-view.git" }, "dependencies": { "prosemirror-model": "^1.25.12", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" }, "devDependencies": { "@prosemirror/buildhelper": "^0.1.5", "prosemirror-test-builder": "^1.0.0" }, "scripts": { "test": "pm-runtests", "prepare": "pm-buildhelper src/index.ts" } } prosemirror-view/src/000077500000000000000000000000001527132543100152035ustar00rootroot00000000000000prosemirror-view/src/README.md000066400000000000000000000010531527132543100164610ustar00rootroot00000000000000ProseMirror's view module displays a given [editor state](#state.EditorState) in the DOM, and handles user events. Make sure you load `style/prosemirror.css` as a stylesheet when using this module. @EditorView ### Props @EditorProps @NodeViewConstructor @MarkViewConstructor @DirectEditorProps @NodeView @MarkView @ViewMutationRecord @DOMEventMap ### Decorations Decorations make it possible to influence the way the document is drawn, without actually changing the document. @Decoration @DecorationAttrs @DecorationSet @DecorationSource prosemirror-view/src/browser.ts000066400000000000000000000025131527132543100172370ustar00rootroot00000000000000const nav = typeof navigator != "undefined" ? navigator : null const doc = typeof document != "undefined" ? document : null const agent = (nav && nav.userAgent) || "" const ie_edge = /Edge\/(\d+)/.exec(agent) const ie_upto10 = /MSIE \d/.exec(agent) const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(agent) export const ie = !!(ie_upto10 || ie_11up || ie_edge) export const ie_version = ie_upto10 ? (document as any).documentMode : ie_11up ? +ie_11up[1] : ie_edge ? +ie_edge[1] : 0 export const gecko = !ie && /gecko\/(\d+)/i.test(agent) export const gecko_version = gecko && +(/Firefox\/(\d+)/.exec(agent) || [0, 0])[1] const _chrome = !ie && /Chrome\/(\d+)/.exec(agent) export const chrome = !!_chrome export const chrome_version = _chrome ? +_chrome[1] : 0 export const safari = !ie && !!nav && /Apple Computer/.test(nav.vendor) // Is true for both iOS and iPadOS for convenience export const ios = safari && (/Mobile\/\w+/.test(agent) || !!nav && nav.maxTouchPoints > 2) export const mac = ios || (nav ? /Mac/.test(nav.platform) : false) export const windows = nav ? /Win/.test(nav.platform) : false export const android = /Android \d/.test(agent) export const webkit = !!doc && "webkitFontSmoothing" in doc.documentElement.style export const webkit_version = webkit ? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1] : 0 prosemirror-view/src/capturekeys.ts000066400000000000000000000321561527132543100201210ustar00rootroot00000000000000import {Selection, NodeSelection, TextSelection, AllSelection, EditorState} from "prosemirror-state" import {EditorView} from "./index" import * as browser from "./browser" import {domIndex, selectionCollapsed, hasBlockDesc, isElt, isText, nodeSize} from "./dom" import {selectionToDOM} from "./selection" function moveSelectionBlock(state: EditorState, dir: number) { let {$anchor, $head} = state.selection let $side = dir > 0 ? $anchor.max($head) : $anchor.min($head) let $start = !$side.parent.inlineContent ? $side : $side.depth ? state.doc.resolve(dir > 0 ? $side.after() : $side.before()) : null return $start && Selection.findFrom($start, dir) } function apply(view: EditorView, sel: Selection) { view.dispatch(view.state.tr.setSelection(sel).scrollIntoView()) return true } function selectHorizontally(view: EditorView, dir: number, mods: string) { let sel = view.state.selection if (sel instanceof TextSelection) { if (mods.indexOf("s") > -1) { let {$head} = sel, node = $head.textOffset ? null : dir < 0 ? $head.nodeBefore : $head.nodeAfter if (!node || node.isText || !node.isLeaf) return false let $newHead = view.state.doc.resolve($head.pos + node.nodeSize * (dir < 0 ? -1 : 1)) return apply(view, new TextSelection(sel.$anchor, $newHead)) } else if (!sel.empty) { return false } else if (view.endOfTextblock(dir > 0 ? "forward" : "backward")) { let next = moveSelectionBlock(view.state, dir) if (next && (next instanceof NodeSelection)) return apply(view, next) return false } else if (!browser.mac || mods.indexOf("m") < 0) { let $head = sel.$head, node = $head.textOffset ? null : dir < 0 ? $head.nodeBefore : $head.nodeAfter, desc if (!node || node.isText) return false let nodePos = dir < 0 ? $head.pos - node.nodeSize : $head.pos if (!(node.isAtom || (desc = view.docView.descAt(nodePos)) && !desc.contentDOM)) return false if (NodeSelection.isSelectable(node)) { return apply(view, new NodeSelection(dir < 0 ? view.state.doc.resolve($head.pos - node.nodeSize) : $head)) } else { // Chrome and Safari will introduce extra pointless cursor // positions around inline uneditable nodes (#937), and // Firefox will sometimes just plain refuse to move across // them (#1592), so in this situation we move the cursor // ourselves. return apply(view, new TextSelection(view.state.doc.resolve(dir < 0 ? nodePos : nodePos + node.nodeSize))) } } } else if (sel instanceof NodeSelection && sel.node.isInline) { return apply(view, new TextSelection(dir > 0 ? sel.$to : sel.$from)) } else { let next = moveSelectionBlock(view.state, dir) if (next) return apply(view, next) return false } } function isIgnorable(dom: Node, dir: number) { let desc = dom.pmViewDesc return desc ? desc.size == 0 && (dir < 0 || dom.nextSibling || dom.nodeName != "BR") : isElt(dom) && dom.contentEditable == "false" } function skipIgnoredNodes(view: EditorView, dir: number) { return dir < 0 ? skipIgnoredNodesBefore(view) : skipIgnoredNodesAfter(view) } // Make sure the cursor isn't directly after one or more ignored // nodes, which will confuse the browser's cursor motion logic. function skipIgnoredNodesBefore(view: EditorView) { let sel = view.domSelectionRange() let node = sel.focusNode!, offset = sel.focusOffset if (!node) return let moveNode, moveOffset: number | undefined, force = false // Gecko will do odd things when the selection is directly in front // of a non-editable node, so in that case, move it into the next // node if possible. Issue prosemirror/prosemirror#832. if (browser.gecko && isElt(node) && offset < nodeSize(node) && isIgnorable(node.childNodes[offset], -1)) force = true for (;;) { if (offset > 0) { if (!isElt(node)) break let before = node.childNodes[offset - 1] if (isIgnorable(before, -1)) { moveNode = node moveOffset = --offset } else if (isText(before)) { node = before offset = node.nodeValue!.length } else { break } } else if (isBlockNode(node)) { break } else { let prev = node.previousSibling while (prev && isIgnorable(prev, -1)) { moveNode = node.parentNode moveOffset = domIndex(prev) prev = prev.previousSibling } if (!prev) { node = node.parentNode! if (node == view.dom) break offset = 0 } else { node = prev offset = nodeSize(node) } } } if (force) setSelFocus(view, node, offset) else if (moveNode) setSelFocus(view, moveNode, moveOffset!) } // Make sure the cursor isn't directly before one or more ignored // nodes. function skipIgnoredNodesAfter(view: EditorView) { let sel = view.domSelectionRange() let node = sel.focusNode!, offset = sel.focusOffset if (!node) return let len = nodeSize(node) let moveNode, moveOffset: number | undefined for (;;) { if (offset < len) { if (!isElt(node)) break let after = node.childNodes[offset] if (isIgnorable(after, 1)) { moveNode = node moveOffset = ++offset } else { break } } else if (isBlockNode(node)) { break } else { let next = node.nextSibling while (next && isIgnorable(next, 1)) { moveNode = next.parentNode moveOffset = domIndex(next) + 1 next = next.nextSibling } if (!next) { node = node.parentNode! if (node == view.dom) break offset = len = 0 } else { node = next offset = 0 len = nodeSize(node) } } } if (moveNode) setSelFocus(view, moveNode, moveOffset!) } function isBlockNode(dom: Node) { let desc = dom.pmViewDesc return desc && desc.node && desc.node.isBlock } function textNodeAfter(node: Node | null, offset: number): Text | undefined { while (node && offset == node.childNodes.length && !hasBlockDesc(node)) { offset = domIndex(node) + 1 node = node.parentNode } while (node && offset < node.childNodes.length) { let next = node.childNodes[offset] if (isText(next)) return next if (isElt(next) && next.contentEditable == "false") break node = next offset = 0 } } function textNodeBefore(node: Node | null, offset: number): Text | undefined { while (node && !offset && !hasBlockDesc(node)) { offset = domIndex(node) node = node.parentNode } while (node && offset) { let next = node.childNodes[offset - 1] if (isText(next)) return next if (isElt(next) && next.contentEditable == "false") break node = next offset = node.childNodes.length } } function setSelFocus(view: EditorView, node: Node, offset: number) { if (!isText(node)) { let before, after if (after = textNodeAfter(node, offset)) { node = after offset = 0 } else if (before = textNodeBefore(node, offset)) { node = before offset = before.nodeValue!.length } } let sel = view.domSelection(), range = view.domSelectionRange() if (!sel) return if (selectionCollapsed(range)) { sel.collapse(node, offset) } else if (sel.extend) { sel.collapse(range.anchorNode, range.anchorOffset) sel.extend(node, offset) } view.domObserver.setCurSelection() let {state} = view // If no state update ends up happening, reset the selection. setTimeout(() => { if (view.state == state) selectionToDOM(view) }, 50) } function findDirection(view: EditorView, pos: number): "rtl" | "ltr" { let $pos = view.state.doc.resolve(pos) if (!(browser.chrome || browser.windows) && $pos.parent.inlineContent) { let coords = view.coordsAtPos(pos) if (pos > $pos.start()) { let before = view.coordsAtPos(pos - 1) let mid = (before.top + before.bottom) / 2 if (mid > coords.top && mid < coords.bottom && Math.abs(before.left - coords.left) > 1) return before.left < coords.left ? "ltr" : "rtl" } if (pos < $pos.end()) { let after = view.coordsAtPos(pos + 1) let mid = (after.top + after.bottom) / 2 if (mid > coords.top && mid < coords.bottom && Math.abs(after.left - coords.left) > 1) return after.left > coords.left ? "ltr" : "rtl" } } let block: Element = view.dom for (let d = $pos.depth; d > 0; d--) if ($pos.node(d).isBlock) { block = view.nodeDOM($pos.before(d)) as Element || block break } let computed = getComputedStyle(block).direction return computed == "rtl" ? "rtl" : "ltr" } // Check whether vertical selection motion would involve node // selections. If so, apply it (if not, the result is left to the // browser) function selectVertically(view: EditorView, dir: number, mods: string) { let sel = view.state.selection if (sel instanceof TextSelection && !sel.empty || mods.indexOf("s") > -1) return false if (browser.mac && mods.indexOf("m") > -1) return false let {$from, $to} = sel if (!$from.parent.inlineContent || view.endOfTextblock(dir < 0 ? "up" : "down")) { let next = moveSelectionBlock(view.state, dir) if (next && (next instanceof NodeSelection)) return apply(view, next) } if (!$from.parent.inlineContent) { let side = dir < 0 ? $from : $to let beyond = sel instanceof AllSelection ? Selection.near(side, dir) : Selection.findFrom(side, dir) return beyond ? apply(view, beyond) : false } return false } function stopNativeHorizontalDelete(view: EditorView, dir: number) { if (!(view.state.selection instanceof TextSelection)) return true let {$head, $anchor, empty} = view.state.selection if (!$head.sameParent($anchor)) return true if (!empty) return false if (view.endOfTextblock(dir > 0 ? "forward" : "backward")) return true let nextNode = !$head.textOffset && (dir < 0 ? $head.nodeBefore : $head.nodeAfter) if (nextNode && !nextNode.isText) { let tr = view.state.tr if (dir < 0) tr.delete($head.pos - nextNode.nodeSize, $head.pos) else tr.delete($head.pos, $head.pos + nextNode.nodeSize) view.dispatch(tr) return true } return false } function switchEditable(view: EditorView, node: HTMLElement, state: string) { view.domObserver.stop() node.contentEditable = state view.domObserver.start() } // Issue #867 / #1090 / https://bugs.chromium.org/p/chromium/issues/detail?id=903821 // In which Safari (and at some point in the past, Chrome) does really // wrong things when the down arrow is pressed when the cursor is // directly at the start of a textblock and has an uneditable node // after it function safariDownArrowBug(view: EditorView) { if (!browser.safari || view.state.selection.$head.parentOffset > 0) return false let {focusNode, focusOffset} = view.domSelectionRange() if (focusNode && isElt(focusNode) && focusOffset == 0 && focusNode.firstChild && isElt(focusNode.firstChild) && focusNode.firstChild.contentEditable == "false") { let child = focusNode.firstChild switchEditable(view, child, "true") setTimeout(() => switchEditable(view, child, "false"), 20) } return false } // A backdrop key mapping used to make sure we always suppress keys // that have a dangerous default effect, even if the commands they are // bound to return false, and to make sure that cursor-motion keys // find a cursor (as opposed to a node selection) when pressed. For // cursor-motion keys, the code in the handlers also takes care of // block selections. function getMods(event: KeyboardEvent) { let result = "" if (event.ctrlKey) result += "c" if (event.metaKey) result += "m" if (event.altKey) result += "a" if (event.shiftKey) result += "s" return result } export function captureKeyDown(view: EditorView, event: KeyboardEvent) { let code = event.keyCode, mods = getMods(event) if (code == 8 || (browser.mac && code == 72 && mods == "c")) { // Backspace, Ctrl-h on Mac return stopNativeHorizontalDelete(view, -1) || skipIgnoredNodes(view, -1) } else if ((code == 46 && !event.shiftKey) || (browser.mac && code == 68 && mods == "c")) { // Delete, Ctrl-d on Mac return stopNativeHorizontalDelete(view, 1) || skipIgnoredNodes(view, 1) } else if (code == 13 || code == 27) { // Enter, Esc return true } else if (code == 37 || (browser.mac && code == 66 && mods == "c")) { // Left arrow, Ctrl-b on Mac let dir = code == 37 ? (findDirection(view, view.state.selection.from) == "ltr" ? -1 : 1) : -1 return selectHorizontally(view, dir, mods) || skipIgnoredNodes(view, dir) } else if (code == 39 || (browser.mac && code == 70 && mods == "c")) { // Right arrow, Ctrl-f on Mac let dir = code == 39 ? (findDirection(view, view.state.selection.from) == "ltr" ? 1 : -1) : 1 return selectHorizontally(view, dir, mods) || skipIgnoredNodes(view, dir) } else if (code == 38 || (browser.mac && code == 80 && mods == "c")) { // Up arrow, Ctrl-p on Mac return selectVertically(view, -1, mods) || skipIgnoredNodes(view, -1) } else if (code == 40 || (browser.mac && code == 78 && mods == "c")) { // Down arrow, Ctrl-n on Mac return safariDownArrowBug(view) || selectVertically(view, 1, mods) || skipIgnoredNodes(view, 1) } else if (mods == (browser.mac ? "m" : "c") && (code == 66 || code == 73 || code == 89 || code == 90)) { // Mod-[biyz] return true } return false } prosemirror-view/src/clipboard.ts000066400000000000000000000277431527132543100175270ustar00rootroot00000000000000import {Slice, Fragment, DOMParser, DOMSerializer, ResolvedPos, NodeType, Node} from "prosemirror-model" import * as browser from "./browser" import {EditorView} from "./index" import {isElt} from "./dom" export function serializeForClipboard(view: EditorView, slice: Slice) { view.someProp("transformCopied", f => { slice = f(slice!, view) }) let context = [], {content, openStart, openEnd} = slice while (openStart > 1 && openEnd > 1 && content.childCount == 1 && content.firstChild!.childCount == 1) { openStart-- openEnd-- let node = content.firstChild! context.push(node.type.name, node.attrs != node.type.defaultAttrs ? node.attrs : null) content = node.content } let serializer = view.someProp("clipboardSerializer") || DOMSerializer.fromSchema(view.state.schema) let doc = detachedDoc(), wrap = doc.createElement("div") wrap.appendChild(serializer.serializeFragment(content, {document: doc})) let firstChild = wrap.firstChild, needsWrap, wrappers = 0 while (firstChild && isElt(firstChild) && (needsWrap = wrapMap[firstChild.nodeName.toLowerCase()])) { for (let i = needsWrap.length - 1; i >= 0; i--) { let wrapper = doc.createElement(needsWrap[i]) while (wrap.firstChild) wrapper.appendChild(wrap.firstChild) wrap.appendChild(wrapper) wrappers++ } firstChild = wrap.firstChild } if (firstChild && isElt(firstChild)) firstChild.setAttribute( "data-pm-slice", `${openStart} ${openEnd}${wrappers ? ` -${wrappers}` : ""} ${JSON.stringify(context)}`) let text = view.someProp("clipboardTextSerializer", f => f(slice, view)) || slice.content.textBetween(0, slice.content.size, "\n\n") return {dom: wrap, text, slice} } // Read a slice of content from the clipboard (or drop data). export function parseFromClipboard(view: EditorView, text: string, html: string | null, plainText: boolean, $context: ResolvedPos) { let inCode = $context.parent.type.spec.code let dom: HTMLElement | undefined, slice: Slice | undefined if (!html && !text) return null let asText = !!text && (plainText || inCode || !html) if (asText) { view.someProp("transformPastedText", f => { text = f(text, inCode || plainText, view) }) if (inCode) { slice = new Slice(Fragment.from(view.state.schema.text(text.replace(/\r\n?/g, "\n"))), 0, 0) view.someProp("transformPasted", f => { slice = f(slice!, view, true) }) return slice } let parsed = view.someProp("clipboardTextParser", f => f(text, $context, plainText, view)) if (parsed) { slice = parsed } else { let marks = $context.marks() let {schema} = view.state, serializer = DOMSerializer.fromSchema(schema) dom = document.createElement("div") text.split(/(?:\r\n?|\n)+/).forEach(block => { let p = dom!.appendChild(document.createElement("p")) if (block) p.appendChild(serializer.serializeNode(schema.text(block, marks))) }) } } else { view.someProp("transformPastedHTML", f => { html = f(html!, view) }) dom = readHTML(html!) if (browser.webkit) restoreReplacedSpaces(dom) } let contextNode = dom && dom.querySelector("[data-pm-slice]") let sliceData = contextNode && /^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(contextNode.getAttribute("data-pm-slice") || "") if (sliceData && sliceData[3]) for (let i = +sliceData[3]; i > 0; i--) { let child = dom!.firstChild while (child && !isElt(child)) child = child.nextSibling if (!child) break dom = child as HTMLElement } if (!slice) { let parser = view.someProp("clipboardParser") || view.someProp("domParser") || DOMParser.fromSchema(view.state.schema) slice = parser.parseSlice(dom!, { preserveWhitespace: !!(asText || sliceData), context: $context, ruleFromNode(dom) { if (dom.nodeName == "BR" && !dom.nextSibling && dom.parentNode && !inlineParents.test(dom.parentNode.nodeName)) return {ignore: true} return null } }) } if (sliceData) { slice = addContext(closeSlice(slice, +sliceData[1], +sliceData[2]), sliceData[4]) } else { // HTML wasn't created by ProseMirror. Make sure top-level siblings are coherent slice = Slice.maxOpen(normalizeSiblings(slice.content, $context), true) if (slice.openStart || slice.openEnd) { let openStart = 0, openEnd = 0 for (let node = slice.content.firstChild; openStart < slice.openStart && !node!.type.spec.isolating; openStart++, node = node!.firstChild) {} for (let node = slice.content.lastChild; openEnd < slice.openEnd && !node!.type.spec.isolating; openEnd++, node = node!.lastChild) {} slice = closeSlice(slice, openStart, openEnd) } } view.someProp("transformPasted", f => { slice = f(slice!, view, asText) }) return slice } const inlineParents = /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i // Takes a slice parsed with parseSlice, which means there hasn't been // any content-expression checking done on the top nodes, tries to // find a parent node in the current context that might fit the nodes, // and if successful, rebuilds the slice so that it fits into that parent. // // This addresses the problem that Transform.replace expects a // coherent slice, and will fail to place a set of siblings that don't // fit anywhere in the schema. function normalizeSiblings(fragment: Fragment, $context: ResolvedPos) { if (fragment.childCount < 2) return fragment for (let d = $context.depth; d >= 0; d--) { let parent = $context.node(d) let match = parent.contentMatchAt($context.index(d)) let lastWrap: readonly NodeType[] | undefined, result: Node[] | null = [] fragment.forEach(node => { if (!result) return let wrap = match.findWrapping(node.type), inLast if (!wrap) return result = null if (inLast = result.length && lastWrap!.length && addToSibling(wrap, lastWrap!, node, result[result.length - 1], 0)) { result[result.length - 1] = inLast } else { if (result.length) result[result.length - 1] = closeRight(result[result.length - 1], lastWrap!.length) let wrapped = withWrappers(node, wrap) result.push(wrapped) match = match.matchType(wrapped.type)! lastWrap = wrap } }) if (result) return Fragment.from(result) } return fragment } function withWrappers(node: Node, wrap: readonly NodeType[], from = 0) { for (let i = wrap.length - 1; i >= from; i--) node = wrap[i].create(null, Fragment.from(node)) return node } // Used to group adjacent nodes wrapped in similar parents by // normalizeSiblings into the same parent node function addToSibling(wrap: readonly NodeType[], lastWrap: readonly NodeType[], node: Node, sibling: Node, depth: number): Node | undefined { if (depth < wrap.length && depth < lastWrap.length && wrap[depth] == lastWrap[depth]) { let inner = addToSibling(wrap, lastWrap, node, sibling.lastChild!, depth + 1) if (inner) return sibling.copy(sibling.content.replaceChild(sibling.childCount - 1, inner)) let match = sibling.contentMatchAt(sibling.childCount) if (match.matchType(depth == wrap.length - 1 ? node.type : wrap[depth + 1])) return sibling.copy(sibling.content.append(Fragment.from(withWrappers(node, wrap, depth + 1)))) } } function closeRight(node: Node, depth: number) { if (depth == 0) return node let fragment = node.content.replaceChild(node.childCount - 1, closeRight(node.lastChild!, depth - 1)) let fill = node.contentMatchAt(node.childCount).fillBefore(Fragment.empty, true)! return node.copy(fragment.append(fill)) } function closeRange(fragment: Fragment, side: number, from: number, to: number, depth: number, openEnd: number) { let node = side < 0 ? fragment.firstChild! : fragment.lastChild!, inner = node.content if (fragment.childCount > 1) openEnd = 0 if (depth < to - 1) inner = closeRange(inner, side, from, to, depth + 1, openEnd) if (depth >= from) inner = side < 0 ? node.contentMatchAt(0)!.fillBefore(inner, openEnd <= depth)!.append(inner) : inner.append(node.contentMatchAt(node.childCount)!.fillBefore(Fragment.empty, true)!) return fragment.replaceChild(side < 0 ? 0 : fragment.childCount - 1, node.copy(inner)) } function closeSlice(slice: Slice, openStart: number, openEnd: number) { if (openStart < slice.openStart) slice = new Slice(closeRange(slice.content, -1, openStart, slice.openStart, 0, slice.openEnd), openStart, slice.openEnd) if (openEnd < slice.openEnd) slice = new Slice(closeRange(slice.content, 1, openEnd, slice.openEnd, 0, 0), slice.openStart, openEnd) return slice } // Trick from jQuery -- some elements must be wrapped in other // elements for innerHTML to work. I.e. if you do `div.innerHTML = // ".."` the table cells are ignored. const wrapMap: {[node: string]: string[]} = { thead: ["table"], tbody: ["table"], tfoot: ["table"], caption: ["table"], colgroup: ["table"], col: ["table", "colgroup"], tr: ["table", "tbody"], td: ["table", "tbody", "tr"], th: ["table", "tbody", "tr"] } function detachedDoc() { return document.implementation.createHTMLDocument("title") } let _policy: any = null function maybeWrapTrusted(html: string): string { let trustedTypes = (window as any).trustedTypes if (!trustedTypes) return html // With the require-trusted-types-for CSP, Chrome will block // innerHTML, even on a detached document. This wraps the string in // a way that makes the browser allow us to use its parser again. if (!_policy) { if (_policy = trustedTypes.defaultPolicy) try { return _policy.createHTML(html) } catch {} _policy = trustedTypes.createPolicy("ProseMirrorClipboard", {createHTML: (s: string) => s}) } return _policy.createHTML(html) } function readHTML(html: string) { let metas = /^(\s*]*>)*/.exec(html) if (metas) html = html.slice(metas[0].length) let doc = detachedDoc(), elt = doc.body let firstTag = /<([a-z][^>\s]+)/i.exec(html), wrap if (wrap = firstTag && wrapMap[firstTag[1].toLowerCase()]) html = wrap.map(n => "<" + n + ">").join("") + html + wrap.map(n => "").reverse().join("") elt.innerHTML = maybeWrapTrusted(html) if (wrap) for (let i = 0; i < wrap.length; i++) elt = elt.querySelector(wrap[i]) || elt // Inline styles defined in the pasted content, so that parse rules pick them up for (let i = 0; i < doc.styleSheets.length; i++) { let style = doc.styleSheets[i] for (let j = 0; j < style.rules.length; j++) { let rule = style.rules[j] if (rule instanceof CSSStyleRule) { let matches = elt.querySelectorAll(rule.selectorText) for (let k = 0; k < matches.length; k++) (matches[k] as HTMLElement).style.cssText += rule.style.cssText } } } return elt } // Webkit browsers do some hard-to-predict replacement of regular // spaces with non-breaking spaces when putting content on the // clipboard. This tries to convert such non-breaking spaces (which // will be wrapped in a plain span on Chrome, a span with class // Apple-converted-space on Safari) back to regular spaces. function restoreReplacedSpaces(dom: HTMLElement) { let nodes = dom.querySelectorAll(browser.chrome ? "span:not([class]):not([style])" : "span.Apple-converted-space") for (let i = 0; i < nodes.length; i++) { let node = nodes[i] if (node.childNodes.length == 1 && node.textContent == "\u00a0" && node.parentNode) node.parentNode.replaceChild(dom.ownerDocument.createTextNode(" "), node) } } function addContext(slice: Slice, context: string) { if (!slice.size) return slice let schema = slice.content.firstChild!.type.schema, array try { array = JSON.parse(context) } catch(e) { return slice } let {content, openStart, openEnd} = slice for (let i = array.length - 2; i >= 0; i -= 2) { let type = schema.nodes[array[i]] if (!type || type.hasRequiredAttrs()) break try { content = Fragment.from(type.create(array[i + 1], content)) } catch(e) { break } openStart++; openEnd++ } return new Slice(content, openStart, openEnd) } prosemirror-view/src/decoration.ts000066400000000000000000000754041527132543100177140ustar00rootroot00000000000000import {Node, Mark, Fragment} from "prosemirror-model" import {Mappable, Mapping} from "prosemirror-transform" import {EditorView} from "./index" import {DOMNode} from "./dom" function compareObjs(a: {[prop: string]: any}, b: {[prop: string]: any}) { if (a == b) return true for (let p in a) if (a[p] !== b[p]) return false for (let p in b) if (!(p in a)) return false return true } class FragmentCursor { index = 0 off = 0 constructor(readonly frag: Fragment) {} nodeAt(off: number) { while (this.off <= off && this.index < this.frag.childCount) { let next = this.frag.child(this.index) if (this.off == off) return next this.off += next.nodeSize this.index++ } return null } } export interface DecorationType { spec: any map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null valid(node: Node, span: Decoration, cur: FragmentCursor): boolean eq(other: DecorationType): boolean destroy(dom: DOMNode): void } export type WidgetConstructor = ((view: EditorView, getPos: () => number | undefined) => DOMNode) | DOMNode export class WidgetType implements DecorationType { spec: any side: number constructor(readonly toDOM: WidgetConstructor, spec: any) { this.spec = spec || noSpec this.side = this.spec.side || 0 } map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null { let {pos, deleted} = mapping.mapResult(span.from + oldOffset, this.side < 0 ? -1 : 1) return deleted ? null : new Decoration(pos - offset, pos - offset, this) } valid() { return true } eq(other: WidgetType) { return this == other || (other instanceof WidgetType && (this.spec.key && this.spec.key == other.spec.key || this.toDOM == other.toDOM && compareObjs(this.spec, other.spec))) } destroy(node: DOMNode) { if (this.spec.destroy) this.spec.destroy(node) } } export class InlineType implements DecorationType { spec: any constructor(readonly attrs: DecorationAttrs, spec: any) { this.spec = spec || noSpec } map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null { let from = mapping.map(span.from + oldOffset, this.spec.inclusiveStart ? -1 : 1) - offset let to = mapping.map(span.to + oldOffset, this.spec.inclusiveEnd ? 1 : -1) - offset return from >= to ? null : new Decoration(from, to, this) } valid(_: Node, span: Decoration) { return span.from < span.to } eq(other: DecorationType): boolean { return this == other || (other instanceof InlineType && compareObjs(this.attrs, other.attrs) && compareObjs(this.spec, other.spec)) } static is(span: Decoration) { return span.type instanceof InlineType } destroy() {} } export class NodeType implements DecorationType { spec: any constructor(readonly attrs: DecorationAttrs, spec: any) { this.spec = spec || noSpec } map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null { let from = mapping.mapResult(span.from + oldOffset, 1) if (from.deleted) return null let to = mapping.mapResult(span.to + oldOffset, -1) if (to.deleted || to.pos <= from.pos) return null return new Decoration(from.pos - offset, to.pos - offset, this) } valid(node: Node, span: Decoration, cursor: FragmentCursor): boolean { let found = cursor.nodeAt(span.from) return !!found && !found.isText && span.from + found.nodeSize == span.to } eq(other: DecorationType): boolean { return this == other || (other instanceof NodeType && compareObjs(this.attrs, other.attrs) && compareObjs(this.spec, other.spec)) } destroy() {} } /// Decoration objects can be provided to the view through the /// [`decorations` prop](#view.EditorProps.decorations). They come in /// several variants—see the static members of this class for details. export class Decoration { /// @internal constructor( /// The start position of the decoration. readonly from: number, /// The end position. Will be the same as `from` for [widget /// decorations](#view.Decoration^widget). readonly to: number, /// @internal readonly type: DecorationType ) {} /// @internal copy(from: number, to: number) { return new Decoration(from, to, this.type) } /// @internal eq(other: Decoration, offset = 0) { return this.type.eq(other.type) && this.from + offset == other.from && this.to + offset == other.to } /// @internal map(mapping: Mappable, offset: number, oldOffset: number) { return this.type.map(mapping, this, offset, oldOffset) } /// Creates a widget decoration, which is a DOM node that's shown in /// the document at the given position. It is recommended that you /// delay rendering the widget by passing a function that will be /// called when the widget is actually drawn in a view, but you can /// also directly pass a DOM node. `getPos` can be used to find the /// widget's current document position. static widget(pos: number, toDOM: WidgetConstructor, spec?: { /// Controls which side of the document position this widget is /// associated with. When negative, it is drawn before a cursor /// at its position, and content inserted at that position ends /// up after the widget. When zero (the default) or positive, the /// widget is drawn after the cursor and content inserted there /// ends up before the widget. /// /// When there are multiple widgets at a given position, their /// `side` values determine the order in which they appear. Those /// with lower values appear first. The ordering of widgets with /// the same `side` value is unspecified. /// /// When `marks` is null, `side` also determines the marks that /// the widget is wrapped in—those of the node before when /// negative, those of the node after when positive. side?: number /// By default, the cursor, when at the position of the widget, /// will be strictly kept on the side indicated by /// [`side`](#view.Decoration^widget^spec.side). Set this to true /// to allow the DOM selection to stay on the other side if the /// client sets it there. /// /// **Note**: Mapping of this decoration, which decides on which /// side insertions at its position appear, will still happen /// according to `side`, and keyboard cursor motion will not, /// without further custom handling, visit both sides of the /// widget. relaxedSide?: boolean /// The precise set of marks to draw around the widget. marks?: readonly Mark[] /// Can be used to control which DOM events, when they bubble out /// of this widget, the editor view should ignore. stopEvent?: (event: Event) => boolean /// When set (defaults to false), selection changes inside the /// widget are ignored, and don't cause ProseMirror to try and /// re-sync the selection with its selection state. ignoreSelection?: boolean /// When comparing decorations of this type (in order to decide /// whether it needs to be redrawn), ProseMirror will by default /// compare the widget DOM node by identity. If you pass a key, /// that key will be compared instead, which can be useful when /// you generate decorations on the fly and don't want to store /// and reuse DOM nodes. Make sure that any widgets with the same /// key are interchangeable—if widgets differ in, for example, /// the behavior of some event handler, they should get /// different keys. key?: string /// Called when the widget decoration is removed or the editor is /// destroyed. destroy?: (node: DOMNode) => void /// Specs allow arbitrary additional properties. [key: string]: any }): Decoration { return new Decoration(pos, pos, new WidgetType(toDOM, spec)) } /// Creates an inline decoration, which adds the given attributes to /// each inline node between `from` and `to`. static inline(from: number, to: number, attrs: DecorationAttrs, spec?: { /// Determines how the left side of the decoration is /// [mapped](#transform.Position_Mapping) when content is /// inserted directly at that position. By default, the decoration /// won't include the new content, but you can set this to `true` /// to make it inclusive. inclusiveStart?: boolean /// Determines how the right side of the decoration is mapped. /// See /// [`inclusiveStart`](#view.Decoration^inline^spec.inclusiveStart). inclusiveEnd?: boolean /// Specs may have arbitrary additional properties. [key: string]: any }) { return new Decoration(from, to, new InlineType(attrs, spec)) } /// Creates a node decoration. `from` and `to` should point precisely /// before and after a node in the document. That node, and only that /// node, will receive the given attributes. static node(from: number, to: number, attrs: DecorationAttrs, spec?: any) { return new Decoration(from, to, new NodeType(attrs, spec)) } /// The spec provided when creating this decoration. Can be useful /// if you've stored extra information in that object. get spec() { return this.type.spec } /// @internal get inline() { return this.type instanceof InlineType } /// @internal get widget() { return this.type instanceof WidgetType } } /// A set of attributes to add to a decorated node. Most properties /// simply directly correspond to DOM attributes of the same name, /// which will be set to the property's value. These are exceptions: export type DecorationAttrs = { /// When non-null, the target node is wrapped in a DOM element of /// this type (and the other attributes are applied to this element). nodeName?: string /// A CSS class name or a space-separated set of class names to be /// _added_ to the classes that the node already had. class?: string /// A string of CSS to be _added_ to the node's existing `style` property. style?: string /// Any other properties are treated as regular DOM attributes. [attribute: string]: string | undefined } const none: readonly any[] = [], noSpec = {} /// An object that can [provide](#view.EditorProps.decorations) /// decorations. Implemented by [`DecorationSet`](#view.DecorationSet), /// and passed to [node views](#view.EditorProps.nodeViews). export interface DecorationSource { /// Map the set of decorations in response to a change in the /// document. map: (mapping: Mapping, node: Node) => DecorationSource /// @internal locals(node: Node): readonly Decoration[] /// Extract a DecorationSource containing decorations for the given child node at the given offset. forChild(offset: number, child: Node): DecorationSource /// @internal eq(other: DecorationSource): boolean /// Call the given function for each decoration set in the group. forEachSet(f: (set: DecorationSet) => void): void } /// A collection of [decorations](#view.Decoration), organized in such /// a way that the drawing algorithm can efficiently use and compare /// them. This is a persistent data structure—it is not modified, /// updates create a new value. export class DecorationSet implements DecorationSource { /// @internal local: readonly Decoration[] /// @internal children: readonly (number | DecorationSet)[] /// @internal constructor(local: readonly Decoration[], children: readonly (number | DecorationSet)[]) { this.local = local.length ? local : none this.children = children.length ? children : none } /// Create a set of decorations, using the structure of the given /// document. This will consume (modify) the `decorations` array, so /// you must make a copy if you want need to preserve that. static create(doc: Node, decorations: Decoration[]) { return decorations.length ? buildTree(decorations, doc, 0, noSpec) : empty } /// Find all decorations in this set which touch the given range /// (including decorations that start or end directly at the /// boundaries) and match the given predicate on their spec. When /// `start` and `end` are omitted, all decorations in the set are /// considered. When `predicate` isn't given, all decorations are /// assumed to match. find(start?: number, end?: number, predicate?: (spec: any) => boolean): Decoration[] { let result: Decoration[] = [] this.findInner(start == null ? 0 : start, end == null ? 1e9 : end, result, 0, predicate) return result } private findInner(start: number, end: number, result: Decoration[], offset: number, predicate?: (spec: any) => boolean) { for (let i = 0; i < this.local.length; i++) { let span = this.local[i] if (span.from <= end && span.to >= start && (!predicate || predicate(span.spec))) result.push(span.copy(span.from + offset, span.to + offset)) } for (let i = 0; i < this.children.length; i += 3) { if ((this.children[i] as number) < end && (this.children[i + 1] as number) > start) { let childOff = (this.children[i] as number) + 1 ;(this.children[i + 2] as DecorationSet).findInner(start - childOff, end - childOff, result, offset + childOff, predicate) } } } /// Map the set of decorations in response to a change in the /// document. map(mapping: Mapping, doc: Node, options?: { /// When given, this function will be called for each decoration /// that gets dropped as a result of the mapping, passing the /// spec of that decoration. onRemove?: (decorationSpec: any) => void }) { if (this == empty || mapping.maps.length == 0) return this return this.mapInner(mapping, doc, 0, 0, options || noSpec) } /// @internal mapInner(mapping: Mapping, node: Node, offset: number, oldOffset: number, options: { onRemove?: (decorationSpec: any) => void }) { let newLocal: Decoration[] | undefined if (this.local.length) { let cur = new FragmentCursor(node.content) for (let i = 0; i < this.local.length; i++) { let mapped = this.local[i].map(mapping, offset, oldOffset) if (mapped && mapped.type.valid(node, mapped, cur)) (newLocal || (newLocal = [])).push(mapped) else if (options.onRemove) options.onRemove(this.local[i].spec) } } if (this.children.length) return mapChildren(this.children, newLocal || [], mapping, node, offset, oldOffset, options) else return newLocal ? new DecorationSet(newLocal.sort(byPos), none) : empty } /// Add the given array of decorations to the ones in the set, /// producing a new set. Consumes the `decorations` array. Needs /// access to the current document to create the appropriate tree /// structure. add(doc: Node, decorations: Decoration[]) { if (!decorations.length) return this if (this == empty) return DecorationSet.create(doc, decorations) return this.addInner(doc, decorations, 0) } private addInner(doc: Node, decorations: Decoration[], offset: number) { let children: (number | DecorationSet)[] | undefined, childIndex = 0 doc.forEach((childNode, childOffset) => { let baseOffset = childOffset + offset, found if (!(found = takeSpansForNode(decorations, childNode, baseOffset))) return if (!children) children = this.children.slice() while (childIndex < children.length && (children[childIndex] as number) < childOffset) childIndex += 3 if (children[childIndex] == childOffset) children[childIndex + 2] = (children[childIndex + 2] as DecorationSet).addInner(childNode, found, baseOffset + 1) else children.splice(childIndex, 0, childOffset, childOffset + childNode.nodeSize, buildTree(found, childNode, baseOffset + 1, noSpec)) childIndex += 3 }) let local = moveSpans(childIndex ? withoutNulls(decorations) : decorations, -offset) if (local.length) { let cur = new FragmentCursor(doc.content) for (let i = 0; i < local.length; i++) if (!local[i].type.valid(doc, local[i], cur)) local.splice(i--, 1) } return new DecorationSet(local.length ? this.local.concat(local).sort(byPos) : this.local, children || this.children) } /// Create a new set that contains the decorations in this set, minus /// the ones in the given array. remove(decorations: Decoration[]) { if (decorations.length == 0 || this == empty) return this return this.removeInner(decorations, 0) } private removeInner(decorations: (Decoration | null)[], offset: number) { let children = this.children as (number | DecorationSet)[], local = this.local as Decoration[] for (let i = 0; i < children.length; i += 3) { let found: Decoration[] | undefined let from = (children[i] as number) + offset, to = (children[i + 1] as number) + offset for (let j = 0, span; j < decorations.length; j++) if (span = decorations[j]) { if (span.from > from && span.to < to) { decorations[j] = null ;(found || (found = [])).push(span) } } if (!found) continue if (children == this.children) children = this.children.slice() let removed = (children[i + 2] as DecorationSet).removeInner(found, from + 1) if (removed != empty) { children[i + 2] = removed } else { children.splice(i, 3) i -= 3 } } if (local.length) for (let i = 0, span; i < decorations.length; i++) if (span = decorations[i]) { for (let j = 0; j < local.length; j++) if (local[j].eq(span, offset)) { if (local == this.local) local = this.local.slice() local.splice(j--, 1) } } if (children == this.children && local == this.local) return this return local.length || children.length ? new DecorationSet(local, children) : empty } forChild(offset: number, node: Node): DecorationSet | DecorationGroup { if (this == empty) return this if (node.isLeaf) return DecorationSet.empty let child, local: Decoration[] | undefined for (let i = 0; i < this.children.length; i += 3) if ((this.children[i] as number) >= offset) { if (this.children[i] == offset) child = this.children[i + 2] as DecorationSet break } let start = offset + 1, end = start + node.content.size for (let i = 0; i < this.local.length; i++) { let dec = this.local[i] if (dec.from < end && dec.to > start && (dec.type instanceof InlineType)) { let from = Math.max(start, dec.from) - start, to = Math.min(end, dec.to) - start if (from < to) (local || (local = [])).push(dec.copy(from, to)) } } if (local) { let localSet = new DecorationSet(local.sort(byPos), none) return child ? new DecorationGroup([localSet, child]) : localSet } return child || empty } /// @internal eq(other: DecorationSet) { if (this == other) return true if (!(other instanceof DecorationSet) || this.local.length != other.local.length || this.children.length != other.children.length) return false for (let i = 0; i < this.local.length; i++) if (!this.local[i].eq(other.local[i])) return false for (let i = 0; i < this.children.length; i += 3) if (this.children[i] != other.children[i] || this.children[i + 1] != other.children[i + 1] || !(this.children[i + 2] as DecorationSet).eq(other.children[i + 2] as DecorationSet)) return false return true } /// @internal locals(node: Node) { return removeOverlap(this.localsInner(node)) } /// @internal localsInner(node: Node): readonly Decoration[] { if (this == empty) return none if (node.inlineContent || !this.local.some(InlineType.is)) return this.local let result = [] for (let i = 0; i < this.local.length; i++) { if (!(this.local[i].type instanceof InlineType)) result.push(this.local[i]) } return result } /// The empty set of decorations. static empty: DecorationSet = new DecorationSet([], []) /// @internal static removeOverlap = removeOverlap forEachSet(f: (set: DecorationSet) => void) { f(this) } } const empty = DecorationSet.empty // An abstraction that allows the code dealing with decorations to // treat multiple DecorationSet objects as if it were a single object // with (a subset of) the same interface. class DecorationGroup implements DecorationSource { constructor(readonly members: readonly DecorationSet[]) {} map(mapping: Mapping, doc: Node) { const mappedDecos = this.members.map( member => member.map(mapping, doc, noSpec) ) return DecorationGroup.from(mappedDecos) } forChild(offset: number, child: Node) { if (child.isLeaf) return DecorationSet.empty let found: DecorationSet[] = [] for (let i = 0; i < this.members.length; i++) { let result = this.members[i].forChild(offset, child) if (result == empty) continue if (result instanceof DecorationGroup) found = found.concat(result.members) else found.push(result) } return DecorationGroup.from(found) } eq(other: DecorationGroup) { if (!(other instanceof DecorationGroup) || other.members.length != this.members.length) return false for (let i = 0; i < this.members.length; i++) if (!this.members[i].eq(other.members[i])) return false return true } locals(node: Node) { let result: Decoration[] | undefined, sorted = true for (let i = 0; i < this.members.length; i++) { let locals = this.members[i].localsInner(node) if (!locals.length) continue if (!result) { result = locals as Decoration[] } else { if (sorted) { result = result.slice() sorted = false } for (let j = 0; j < locals.length; j++) result.push(locals[j]) } } return result ? removeOverlap(sorted ? result : result.sort(byPos)) : none } // Create a group for the given array of decoration sets, or return // a single set when possible. static from(members: readonly DecorationSource[]): DecorationSource { switch (members.length) { case 0: return empty case 1: return members[0] default: return new DecorationGroup( members.every(m => m instanceof DecorationSet) ? members as DecorationSet[] : members.reduce((r, m) => r.concat(m instanceof DecorationSet ? m : (m as DecorationGroup).members), [] as DecorationSet[])) } } forEachSet(f: (set: DecorationSet) => void) { for (let i = 0; i < this.members.length; i++) this.members[i].forEachSet(f) } } function mapChildren( oldChildren: readonly (number | DecorationSet)[], newLocal: Decoration[], mapping: Mapping, node: Node, offset: number, oldOffset: number, options: {onRemove?: (decorationSpec: any) => void} ) { let children = oldChildren.slice() as (number | DecorationSet)[] // Mark the children that are directly touched by changes, and // move those that are after the changes. for (let i = 0, baseOffset = oldOffset; i < mapping.maps.length; i++) { let moved = 0 mapping.maps[i].forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => { let dSize = (newEnd - newStart) - (oldEnd - oldStart) for (let i = 0; i < children.length; i += 3) { let end = children[i + 1] as number if (end < 0 || oldStart > end + baseOffset - moved) continue let start = (children[i] as number) + baseOffset - moved if (oldEnd >= start) { children[i + 1] = oldStart <= start ? -2 : -1 } else if (oldStart >= baseOffset && dSize) { ;(children[i] as number) += dSize ;(children[i + 1] as number) += dSize } } moved += dSize }) baseOffset = mapping.maps[i].map(baseOffset, -1) } // Find the child nodes that still correspond to a single node, // recursively call mapInner on them and update their positions. let mustRebuild = false for (let i = 0; i < children.length; i += 3) if ((children[i + 1] as number) < 0) { // Touched nodes if (children[i + 1] == -2) { mustRebuild = true children[i + 1] = -1 continue } let from = mapping.map((oldChildren[i] as number) + oldOffset), fromLocal = from - offset if (fromLocal < 0 || fromLocal >= node.content.size) { mustRebuild = true continue } // Must read oldChildren because children was tagged with -1 let to = mapping.map((oldChildren[i + 1] as number) + oldOffset, -1), toLocal = to - offset let {index, offset: childOffset} = node.content.findIndex(fromLocal) let childNode = node.maybeChild(index) if (childNode && childOffset == fromLocal && childOffset + childNode.nodeSize == toLocal) { let mapped = (children[i + 2] as DecorationSet) .mapInner(mapping, childNode, from + 1, (oldChildren[i] as number) + oldOffset + 1, options) if (mapped != empty) { children[i] = fromLocal children[i + 1] = toLocal children[i + 2] = mapped } else { children[i + 1] = -2 mustRebuild = true } } else { mustRebuild = true } } // Remaining children must be collected and rebuilt into the appropriate structure if (mustRebuild) { let decorations = mapAndGatherRemainingDecorations(children, oldChildren, newLocal, mapping, offset, oldOffset, options) let built = buildTree(decorations, node, 0, options) newLocal = built.local as Decoration[] for (let i = 0; i < children.length; i += 3) if ((children[i + 1] as number) < 0) { children.splice(i, 3) i -= 3 } for (let i = 0, j = 0; i < built.children.length; i += 3) { let from = built.children[i] while (j < children.length && children[j] < from) j += 3 children.splice(j, 0, built.children[i], built.children[i + 1], built.children[i + 2]) } } return new DecorationSet(newLocal.sort(byPos), children) } function moveSpans(spans: Decoration[], offset: number) { if (!offset || !spans.length) return spans let result = [] for (let i = 0; i < spans.length; i++) { let span = spans[i] result.push(new Decoration(span.from + offset, span.to + offset, span.type)) } return result } function mapAndGatherRemainingDecorations( children: (number | DecorationSet)[], oldChildren: readonly (number | DecorationSet)[], decorations: Decoration[], mapping: Mapping, offset: number, oldOffset: number, options: {onRemove?: (decorationSpec: any) => void} ) { // Gather all decorations from the remaining marked children function gather(set: DecorationSet, oldOffset: number) { for (let i = 0; i < set.local.length; i++) { let mapped = set.local[i].map(mapping, offset, oldOffset) if (mapped) decorations.push(mapped) else if (options.onRemove) options.onRemove(set.local[i].spec) } for (let i = 0; i < set.children.length; i += 3) gather(set.children[i + 2] as DecorationSet, set.children[i] as number + oldOffset + 1) } for (let i = 0; i < children.length; i += 3) if (children[i + 1] == -1) gather(children[i + 2] as DecorationSet, oldChildren[i] as number + oldOffset + 1) return decorations } function takeSpansForNode(spans: (Decoration | null)[], node: Node, offset: number): Decoration[] | null { if (node.isLeaf) return null let end = offset + node.nodeSize, found = null for (let i = 0, span; i < spans.length; i++) { if ((span = spans[i]) && span.from > offset && span.to < end) { ;(found || (found = [])).push(span) spans[i] = null } } return found } function withoutNulls(array: readonly (T | null)[]): T[] { let result: T[] = [] for (let i = 0; i < array.length; i++) if (array[i] != null) result.push(array[i]!) return result } // Build up a tree that corresponds to a set of decorations. `offset` // is a base offset that should be subtracted from the `from` and `to` // positions in the spans (so that we don't have to allocate new spans // for recursive calls). function buildTree( spans: Decoration[], node: Node, offset: number, options: {onRemove?: (decorationSpec: any) => void} ) { let children: (DecorationSet | number)[] = [], hasNulls = false node.forEach((childNode, localStart) => { let found = takeSpansForNode(spans, childNode, localStart + offset) if (found) { hasNulls = true let subtree = buildTree(found, childNode, offset + localStart + 1, options) if (subtree != empty) children.push(localStart, localStart + childNode.nodeSize, subtree) } }) let locals = moveSpans(hasNulls ? withoutNulls(spans) : spans, -offset).sort(byPos) if (locals.length) { let cur = new FragmentCursor(node.content) for (let i = 0; i < locals.length; i++) if (!locals[i].type.valid(node, locals[i], cur)) { if (options.onRemove) options.onRemove(locals[i].spec) locals.splice(i--, 1) } } return locals.length || children.length ? new DecorationSet(locals, children) : empty } // Used to sort decorations so that ones with a low start position // come first, and within a set with the same start position, those // with an smaller end position come first. function byPos(a: Decoration, b: Decoration) { return a.from - b.from || a.to - b.to } // Scan a sorted array of decorations for partially overlapping spans, // and split those so that only fully overlapping spans are left (to // make subsequent rendering easier). Will return the input array if // no partially overlapping spans are found (the common case). function removeOverlap(spans: readonly Decoration[]): Decoration[] { let working: Decoration[] = spans as Decoration[] for (let i = 0; i < working.length - 1; i++) { let span = working[i] if (span.from != span.to) for (let j = i + 1; j < working.length; j++) { let next = working[j] if (next.from == span.from) { if (next.to != span.to) { if (working == spans) working = spans.slice() // Followed by a partially overlapping larger span. Split that // span. working[j] = next.copy(next.from, span.to) insertAhead(working, j + 1, next.copy(span.to, next.to)) } continue } else { if (next.from < span.to) { if (working == spans) working = spans.slice() // The end of this one overlaps with a subsequent span. Split // this one. working[i] = span.copy(span.from, next.from) insertAhead(working, j, span.copy(next.from, span.to)) } break } } } return working } function insertAhead(array: Decoration[], i: number, deco: Decoration) { while (i < array.length && byPos(deco, array[i]) > 0) i++ array.splice(i, 0, deco) } // Get the decorations associated with the current props of a view. export function viewDecorations(view: EditorView): DecorationSource { let found: DecorationSource[] = [] view.someProp("decorations", f => { let result = f(view.state) if (result && result != empty) found.push(result) }) if (view.cursorWrapper) found.push(DecorationSet.create(view.state.doc, [view.cursorWrapper.deco])) return DecorationGroup.from(found) } prosemirror-view/src/dom.ts000066400000000000000000000137401527132543100163370ustar00rootroot00000000000000export type DOMNode = InstanceType export type DOMSelection = InstanceType export type DOMSelectionRange = { focusNode: DOMNode | null, focusOffset: number, anchorNode: DOMNode | null, anchorOffset: number } export const domIndex = function(node: Node) { for (var index = 0;; index++) { node = node.previousSibling! if (!node) return index } } export const parentNode = function(node: Node): Node | null { let parent = (node as HTMLSlotElement).assignedSlot || node.parentNode return parent && parent.nodeType == 11 ? (parent as ShadowRoot).host : parent } let reusedRange: Range | null = null // Note that this will always return the same range, because DOM range // objects are every expensive, and keep slowing down subsequent DOM // updates, for some reason. export const textRange = function(node: Text, from?: number, to?: number) { let range = reusedRange || (reusedRange = document.createRange()) range.setEnd(node, to == null ? node.nodeValue!.length : to) range.setStart(node, from || 0) return range } export const clearReusedRange = function() { reusedRange = null } // 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 const isEquivalentPosition = function(node: Node, off: number, targetNode: Node, targetOff: number) { return targetNode && (scanFor(node, off, targetNode, targetOff, -1) || scanFor(node, off, targetNode, targetOff, 1)) } const atomElements = /^(img|br|input|textarea|hr)$/i function scanFor(node: Node, off: number, targetNode: Node, targetOff: number, dir: number) { for (;;) { if (node == targetNode && off == targetOff) return true if (off == (dir < 0 ? 0 : nodeSize(node))) { let parent = node.parentNode if (!parent || !isElt(parent) || hasBlockDesc(node) || atomElements.test(node.nodeName) || (node as HTMLElement).contentEditable == "false") return false off = domIndex(node) + (dir < 0 ? 0 : 1) node = parent } else if (isElt(node)) { let child = node.childNodes[off + (dir < 0 ? -1 : 0)] if (isElt(child) && child.contentEditable == "false") { if (child.pmViewDesc?.ignoreForSelection) off += dir else return false } else { node = child off = dir < 0 ? nodeSize(node) : 0 } } else { return false } } } export function nodeSize(node: Node) { return node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length } export function isElt(node: Node): node is HTMLElement { return node.nodeType == 1 } export function isText(node: Node): node is Text { return node.nodeType == 3 } export function contains(parent: Node, child: Node | null) { return child ? parent.contains(isElt(child) ? child : child.parentNode) : false } export function textNodeBefore(node: Node, offset: number) { for (;;) { if (isText(node) && offset) return node if (isElt(node) && offset > 0) { if ((node as HTMLElement).contentEditable == "false") return null node = node.childNodes[offset - 1] offset = nodeSize(node) } else if (node.parentNode && !hasBlockDesc(node)) { offset = domIndex(node) node = node.parentNode } else { return null } } } export function textNodeAfter(node: Node, offset: number) { for (;;) { if (isText(node) && offset < node.nodeValue!.length) return node as Text if (isElt(node) && offset < node.childNodes.length) { if (node.contentEditable == "false") return null node = node.childNodes[offset] offset = 0 } else if (node.parentNode && !hasBlockDesc(node)) { offset = domIndex(node) + 1 node = node.parentNode } else { return null } } } export function isOnEdge(node: Node, offset: number, parent: Node) { for (let atStart = offset == 0, atEnd = offset == nodeSize(node); atStart || atEnd;) { if (node == parent) return true let index = domIndex(node) node = node.parentNode! if (!node) return false atStart = atStart && index == 0 atEnd = atEnd && index == nodeSize(node) } } export function hasBlockDesc(dom: Node) { let desc for (let cur: Node | null = dom; cur; cur = cur.parentNode) if (desc = cur.pmViewDesc) break return desc && desc.node && desc.node.isBlock && (desc.dom == dom || desc.contentDOM == dom) } // Work around Chrome issue https://bugs.chromium.org/p/chromium/issues/detail?id=447523 // (isCollapsed inappropriately returns true in shadow dom) export const selectionCollapsed = function(domSel: DOMSelectionRange) { return domSel.focusNode && isEquivalentPosition(domSel.focusNode, domSel.focusOffset, domSel.anchorNode!, domSel.anchorOffset) } export function keyEvent(keyCode: number, key: string) { let event = document.createEvent("Event") as KeyboardEvent event.initEvent("keydown", true, true) ;(event as any).keyCode = keyCode ;(event as any).key = (event as any).code = key return event } export function deepActiveElement(doc: Document) { let elt = doc.activeElement while (elt && elt.shadowRoot) elt = elt.shadowRoot.activeElement return elt } export function caretFromPoint(doc: Document, x: number, y: number): {node: Node, offset: number} | undefined { if ((doc as any).caretPositionFromPoint) { try { // Firefox throws for this call in hard-to-predict circumstances (#994) let pos = (doc as any).caretPositionFromPoint(x, y) // Clip the offset, because Chrome will return a text offset // into nodes, which can't be treated as a regular DOM // offset if (pos) return {node: pos.offsetNode, offset: Math.min(nodeSize(pos.offsetNode), pos.offset)} } catch (_) {} } if (doc.caretRangeFromPoint) { let range = doc.caretRangeFromPoint(x, y) if (range) return {node: range.startContainer, offset: Math.min(nodeSize(range.startContainer), range.startOffset)} } } prosemirror-view/src/domchange.ts000066400000000000000000000406761527132543100175150ustar00rootroot00000000000000import {Fragment, DOMParser, TagParseRule, Node, Mark, ResolvedPos} from "prosemirror-model" import {TextSelection, Transaction} from "prosemirror-state" import {selectionBetween, selectionFromDOM, selectionToDOM} from "./selection" import {selectionCollapsed, DOMNode, isElt, contains} from "./dom" import * as browser from "./browser" import {EditorView} from "./index" import {dispatchKey} from "./input" // Note that all referencing and parsing is done with the // start-of-operation selection and document, since that's the one // that the DOM represents. If any changes came in in the meantime, // the modification is mapped over those before it is applied, in // readDOMChange. function parseBetween(view: EditorView, from_: number, to_: number, addedNodes: readonly DOMNode[]) { let {node: parent, fromOffset, toOffset, from, to} = view.docView.parseRange(from_, to_) let domSel = view.domSelectionRange() let find: {node: DOMNode, offset: number, pos?: number}[] | undefined let anchor = domSel.anchorNode if (anchor && contains(view.dom, anchor)) { find = [{node: anchor, offset: domSel.anchorOffset}] if (!selectionCollapsed(domSel)) find.push({node: domSel.focusNode!, offset: domSel.focusOffset}) } // Work around issue in Chrome where backspacing sometimes replaces // the deleted content with a random BR node (issues #799, #831) if (browser.chrome && view.input.lastKeyCode === 8) { for (let off = toOffset; off > fromOffset; off--) { let node = parent.childNodes[off - 1], desc = node.pmViewDesc if (node.nodeName == "BR" && !desc) { toOffset = off; break } if (!desc || desc.size) break } } let startDoc = view.state.doc let parser = view.someProp("domParser") || DOMParser.fromSchema(view.state.schema) let $from = startDoc.resolve(from) let sel = null, doc = parser.parse(parent, { topNode: $from.parent, topMatch: $from.parent.contentMatchAt($from.index()), topOpen: true, from: fromOffset, to: toOffset, preserveWhitespace: $from.parent.type.whitespace == "pre" ? "full" : true, findPositions: find, ruleFromNode: ruleFromNode(addedNodes), context: $from }) if (find && find[0].pos != null) { let anchor = find[0].pos, head = find[1] && find[1].pos if (head == null) head = anchor sel = {anchor: anchor + from, head: head + from} } return {doc, sel, from, to} } const ruleFromNode = (added: readonly DOMNode[]) => (dom: DOMNode): Omit | null => { let desc = dom.pmViewDesc if (desc) { return desc.parseRule(added) } else if (dom.nodeName == "BR" && dom.parentNode) { // Safari replaces the list item or table cell with a BR // directly in the list node (?!) if you delete the last // character in a list item or table cell (#708, #862) if (browser.safari && /^(ul|ol)$/i.test(dom.parentNode.nodeName)) { let skip = document.createElement("div") skip.appendChild(document.createElement("li")) return {skip} as any } else if (dom.parentNode.lastChild == dom || browser.safari && /^(tr|table)$/i.test(dom.parentNode.nodeName)) { return {ignore: true} } } else if (dom.nodeName == "IMG" && (dom as HTMLElement).getAttribute("mark-placeholder")) { return {ignore: true} } return null } const isInline = /^(a|abbr|acronym|b|bd[io]|big|br|button|cite|code|data(list)?|del|dfn|em|i|img|ins|kbd|label|map|mark|meter|output|q|ruby|s|samp|small|span|strong|su[bp]|time|u|tt|var)$/i export function readDOMChange(view: EditorView, from: number, to: number, typeOver: boolean, addedNodes: readonly DOMNode[]) { let compositionID = view.input.compositionPendingChanges || (view.composing ? view.input.compositionID : 0) view.input.compositionPendingChanges = 0 if (from < 0) { let origin = view.input.lastSelectionTime > Date.now() - 50 ? view.input.lastSelectionOrigin : null let newSel = selectionFromDOM(view, origin) if (newSel && !view.state.selection.eq(newSel)) { if (browser.chrome && browser.android && view.input.lastKeyCode === 13 && Date.now() - 100 < view.input.lastKeyCodeTime && dispatchKey(view, "Enter")) return let tr = view.state.tr.setSelection(newSel) if (origin == "pointer") tr.setMeta("pointer", true) else if (origin == "key") tr.scrollIntoView() if (compositionID) tr.setMeta("composition", compositionID) view.dispatch(tr) } return } let $before = view.state.doc.resolve(from) let shared = $before.sharedDepth(to) from = $before.before(shared + 1) to = view.state.doc.resolve(to).after(shared + 1) let sel = view.state.selection let parse = parseBetween(view, from, to, addedNodes) let doc = view.state.doc, compare = doc.slice(parse.from, parse.to) let preferredPos, preferredSide: "start" | "end" // Prefer anchoring to end when Backspace is pressed if (view.input.lastKeyCode === 8 && Date.now() - 100 < view.input.lastKeyCodeTime) { preferredPos = view.state.selection.to preferredSide = "end" } else { preferredPos = view.state.selection.from preferredSide = "start" } view.input.lastKeyCode = null let change = findDiff(compare.content, parse.doc.content, parse.from, preferredPos, preferredSide) if (change) view.input.domChangeCount++ if ((browser.ios && view.input.lastIOSEnter > Date.now() - 225 || browser.android) && addedNodes.some(n => isElt(n) && !isInline.test(n.nodeName)) && (!change || change.endA >= change.endB) && dispatchKey(view, "Enter")) { view.input.lastIOSEnter = 0 return } if (!change) { if (typeOver && sel instanceof TextSelection && !sel.empty && sel.$head.sameParent(sel.$anchor) && !view.composing && !(parse.sel && parse.sel.anchor != parse.sel.head)) { change = {start: sel.from, endA: sel.to, endB: sel.to} } else { if (parse.sel) { let sel = resolveSelection(view, view.state.doc, parse.sel) if (sel && !sel.eq(view.state.selection)) { let tr = view.state.tr.setSelection(sel) if (compositionID) tr.setMeta("composition", compositionID) view.dispatch(tr) } } return } } // Handle the case where overwriting a selection by typing matches // the start or end of the selected content, creating a change // that's smaller than what was actually overwritten. if (view.state.selection.from < view.state.selection.to && change.start == change.endB && view.state.selection instanceof TextSelection) { if (change.start > view.state.selection.from && change.start <= view.state.selection.from + 2 && view.state.selection.from >= parse.from) { change.start = view.state.selection.from } else if (change.endA < view.state.selection.to && change.endA >= view.state.selection.to - 2 && view.state.selection.to <= parse.to) { change.endB += (view.state.selection.to - change.endA) change.endA = view.state.selection.to } } // IE11 will insert a non-breaking space _ahead_ of the space after // the cursor space when adding a space before another space. When // that happened, adjust the change to cover the space instead. if (browser.ie && browser.ie_version <= 11 && change.endB == change.start + 1 && change.endA == change.start && change.start > parse.from && parse.doc.textBetween(change.start - parse.from - 1, change.start - parse.from + 1) == " \u00a0") { change.start-- change.endA-- change.endB-- } let $from = parse.doc.resolveNoCache(change.start - parse.from) let $to = parse.doc.resolveNoCache(change.endB - parse.from) let $fromA = doc.resolve(change.start) let inlineChange = $from.sameParent($to) && $from.parent.inlineContent && $fromA.end() >= change.endA // If this looks like the effect of pressing Enter (or was recorded // as being an iOS enter press), just dispatch an Enter key instead. if (((browser.ios && view.input.lastIOSEnter > Date.now() - 225 && (!inlineChange || addedNodes.some(n => n.nodeName == "DIV" || n.nodeName == "P"))) || (!inlineChange && looksLikeEnter(parse.doc, $from.pos, $to.pos))) && dispatchKey(view, "Enter")) { view.input.lastIOSEnter = 0 return } // Same for backspace if (view.state.selection.anchor > change.start && (inlineChange ? change.endA > change.start && change.endB == change.start : looksLikeBlockJoin(doc, change.start, change.endA, $from, $to)) && dispatchKey(view, "Backspace")) { if (browser.android && browser.chrome) view.domObserver.suppressSelectionUpdates() // #820 return } // Chrome will occasionally, during composition, delete the // entire composition and then immediately insert it again. This is // used to detect that situation. if (browser.chrome && change.endB == change.start) view.input.lastChromeDelete = Date.now() // This tries to detect Android virtual keyboard // enter-and-pick-suggestion action. That sometimes (see issue // #1059) first fires a DOM mutation, before moving the selection to // the newly created block. And then, because ProseMirror cleans up // the DOM selection, it gives up moving the selection entirely, // leaving the cursor in the wrong place. When that happens, we drop // the new paragraph from the initial change, and fire a simulated // enter key afterwards. if (browser.android && !inlineChange && $from.start() != $to.start() && $to.parentOffset == 0 && $from.depth == $to.depth && parse.sel && parse.sel.anchor == parse.sel.head && parse.sel.head == change.endA) { change.endB -= 2 $to = parse.doc.resolveNoCache(change.endB - parse.from) setTimeout(() => dispatchKey(view, "Enter"), 20) } let chFrom = change.start, chTo = change.endA let mkTr = (base?: Transaction) => { let tr = base || view.state.tr.replace(chFrom, chTo, parse.doc.slice(change!.start - parse.from, change!.endB - parse.from)) if (parse.sel) { let sel = resolveSelection(view, tr.doc, parse.sel) // Chrome will sometimes, during composition, report the // selection in the wrong place. If it looks like that is // happening, don't update the selection. // Edge just doesn't move the cursor forward when you start typing // in an empty block or between br nodes. if (sel && !(browser.chrome && view.composing && sel.empty && (change!.start != change!.endB || view.input.lastChromeDelete < Date.now() - 100) && (sel.head == chFrom || sel.head == tr.mapping.map(chTo) - 1) || browser.ie && sel.empty && sel.head == chFrom)) tr.setSelection(sel) } if (compositionID) tr.setMeta("composition", compositionID) return tr.scrollIntoView() } let markChange if (inlineChange) { if ($from.pos == $to.pos) { // Deletion // IE11 sometimes weirdly moves the DOM selection around after // backspacing out the first element in a textblock if (browser.ie && browser.ie_version <= 11 && $from.parentOffset == 0) { view.domObserver.suppressSelectionUpdates() setTimeout(() => selectionToDOM(view), 20) } let tr = mkTr(view.state.tr.delete(chFrom, chTo)) let marks = doc.resolve(change.start).marksAcross(doc.resolve(change.endA)) if (marks) tr.ensureMarks(marks) view.dispatch(tr) } else if ( // Adding or removing a mark change.endA == change.endB && (markChange = isMarkChange($from.parent.content.cut($from.parentOffset, $to.parentOffset), $fromA.parent.content.cut($fromA.parentOffset, change.endA - $fromA.start()))) ) { let tr = mkTr(view.state.tr) if (markChange.type == "add") tr.addMark(chFrom, chTo, markChange.mark) else tr.removeMark(chFrom, chTo, markChange.mark) view.dispatch(tr) } else if ($from.parent.child($from.index()).isText && $from.index() == $to.index() - ($to.textOffset ? 0 : 1)) { // Both positions in the same text node -- simply insert text let text = $from.parent.textBetween($from.parentOffset, $to.parentOffset) let deflt = () => mkTr(view.state.tr.insertText(text, chFrom, chTo)) if (!view.someProp("handleTextInput", f => f(view, chFrom, chTo, text, deflt))) view.dispatch(deflt()) } else { view.dispatch(mkTr()) } } else { view.dispatch(mkTr()) } } function resolveSelection(view: EditorView, doc: Node, parsedSel: {anchor: number, head: number}) { if (Math.max(parsedSel.anchor, parsedSel.head) > doc.content.size) return null return selectionBetween(view, doc.resolve(parsedSel.anchor), doc.resolve(parsedSel.head)) } // Given two same-length, non-empty fragments of inline content, // determine whether the first could be created from the second by // removing or adding a single mark type. function isMarkChange(cur: Fragment, prev: Fragment) { let curMarks = cur.firstChild!.marks, prevMarks = prev.firstChild!.marks let added = curMarks, removed = prevMarks, type, mark: Mark | undefined, update for (let i = 0; i < prevMarks.length; i++) added = prevMarks[i].removeFromSet(added) for (let i = 0; i < curMarks.length; i++) removed = curMarks[i].removeFromSet(removed) if (added.length == 1 && removed.length == 0) { mark = added[0] type = "add" update = (node: Node) => node.mark(mark!.addToSet(node.marks)) } else if (added.length == 0 && removed.length == 1) { mark = removed[0] type = "remove" update = (node: Node) => node.mark(mark!.removeFromSet(node.marks)) } else { return null } let updated = [] for (let i = 0; i < prev.childCount; i++) updated.push(update(prev.child(i))) if (Fragment.from(updated).eq(cur)) return {mark, type} } function looksLikeBlockJoin(old: Node, start: number, end: number, $newStart: ResolvedPos, $newEnd: ResolvedPos) { if (// The content must have shrunk end - start <= $newEnd.pos - $newStart.pos || // newEnd must point directly at or after the end of the block that newStart points into skipClosingAndOpening($newStart, true, false) < $newEnd.pos) return false let $start = old.resolve(start) // Handle the case where, rather than joining blocks, the change just removed an entire block if (!$newStart.parent.inlineContent) { let after = $start.nodeAfter return after != null && end == start + after.nodeSize } // Start must be at the end of a block if ($start.parentOffset < $start.parent.content.size || !$start.parent.isTextblock) return false let $next = old.resolve(skipClosingAndOpening($start, true, true)) // The next textblock must start before end and end near it if (!$next.parent.isTextblock || $next.pos > end || skipClosingAndOpening($next, true, false) < end) return false // The fragments after the join point must match return $newStart.parent.content.cut($newStart.parentOffset).eq($next.parent.content) } function looksLikeEnter(content: Node, from: number, to: number) { let newTextlines = 0, newLeaves = 0 content.nodesBetween(from, to, (node, pos) => { if (node.isTextblock && pos >= from && pos < to) newTextlines++ else if (node.isText ? /\S/.test(node.text!.slice(Math.max(0, from - pos), Math.min(node.nodeSize, to - pos))) : node.isLeaf) newLeaves++ }) return newLeaves == 0 && newTextlines == 1 } function skipClosingAndOpening($pos: ResolvedPos, fromEnd: boolean, mayOpen: boolean) { let depth = $pos.depth, end = fromEnd ? $pos.end() : $pos.pos while (depth > 0 && (fromEnd || $pos.indexAfter(depth) == $pos.node(depth).childCount)) { depth-- end++ fromEnd = false } if (mayOpen) { let next = $pos.node(depth).maybeChild($pos.indexAfter(depth)) while (next && !next.isLeaf) { next = next.firstChild end++ } } return end } function findDiff(a: Fragment, b: Fragment, pos: number, preferredPos: number, preferredSide: "start" | "end") { let start = a.findDiffStart(b, pos), lenA = pos + a.size, lenB = pos + b.size if (start == null) return null let {a: endA, b: endB} = a.findDiffEnd(b, lenA, lenB)! if (preferredSide == "end") { let adjust = Math.max(0, start - Math.min(endA, endB)) preferredPos -= endA + adjust - start } if (endA < start && lenA < lenB) { let move = preferredPos <= start && preferredPos >= endA ? start - preferredPos : 0 start -= move endB = start + (endB - endA) endA = start } else if (endB < start) { let move = preferredPos <= start && preferredPos >= endB ? start - preferredPos : 0 start -= move endA = start + (endA - endB) endB = start } return {start, endA, endB} } prosemirror-view/src/domcoords.ts000066400000000000000000000553331527132543100175550ustar00rootroot00000000000000import {EditorState} from "prosemirror-state" import {nodeSize, textRange, parentNode, caretFromPoint, isElt, isText, contains} from "./dom" import * as browser from "./browser" import {EditorView} from "./index" export type Rect = {left: number, right: number, top: number, bottom: number} function windowRect(doc: Document): Rect { let vp = doc.defaultView && doc.defaultView.visualViewport if (vp) return { left: 0, right: vp.width, top: 0, bottom: vp.height } return {left: 0, right: doc.documentElement.clientWidth, top: 0, bottom: doc.documentElement.clientHeight} } function getSide(value: number | Rect, side: keyof Rect): number { return typeof value == "number" ? value : value[side] } function clientRect(node: HTMLElement): Rect { let rect = node.getBoundingClientRect() // Adjust for elements with style "transform: scale()" let scaleX = (rect.width / node.offsetWidth) || 1 let scaleY = (rect.height / node.offsetHeight) || 1 // Make sure scrollbar width isn't included in the rectangle return {left: rect.left, right: rect.left + node.clientWidth * scaleX, top: rect.top, bottom: rect.top + node.clientHeight * scaleY} } export function scrollRectIntoView(view: EditorView, rect: Rect, startDOM: Node) { // Skip empty rects with all sides at 0, for example, when the element has no CSS box (display: none) if (!nonZero(rect) && rect.left == 0) return let scrollThreshold = view.someProp("scrollThreshold") || 0, scrollMargin = view.someProp("scrollMargin") || 5 let doc = view.dom.ownerDocument for (let parent: Node | null = startDOM || view.dom;;) { if (!parent) break if (!isElt(parent)) { parent = parentNode(parent); continue } let atTop = parent == doc.body let bounding = atTop ? windowRect(doc) : clientRect(parent) let moveX = 0, moveY = 0 if (rect.top < bounding.top + getSide(scrollThreshold, "top")) moveY = -(bounding.top - rect.top + getSide(scrollMargin, "top")) else if (rect.bottom > bounding.bottom - getSide(scrollThreshold, "bottom")) moveY = rect.bottom - rect.top > bounding.bottom - bounding.top ? rect.top + getSide(scrollMargin, "top") - bounding.top : rect.bottom - bounding.bottom + getSide(scrollMargin, "bottom") if (rect.left < bounding.left + getSide(scrollThreshold, "left")) moveX = -(bounding.left - rect.left + getSide(scrollMargin, "left")) else if (rect.right > bounding.right - getSide(scrollThreshold, "right")) moveX = rect.right - bounding.right + getSide(scrollMargin, "right") if (moveX || moveY) { if (atTop) { doc.defaultView!.scrollBy(moveX, moveY) } else { let startX = parent.scrollLeft, startY = parent.scrollTop if (moveY) parent.scrollTop += moveY if (moveX) parent.scrollLeft += moveX let dX = parent.scrollLeft - startX, dY = parent.scrollTop - startY rect = {left: rect.left - dX, top: rect.top - dY, right: rect.right - dX, bottom: rect.bottom - dY} } } let pos: string = atTop ? "fixed" : getComputedStyle(parent).position if (/^(fixed|sticky)$/.test(pos)) break parent = pos == "absolute" ? parent.offsetParent : parentNode(parent) } } // Store the scroll position of the editor's parent nodes, along with // the top position of an element near the top of the editor, which // will be used to make sure the visible viewport remains stable even // when the size of the content above changes. export function storeScrollPos(view: EditorView): { refDOM: HTMLElement, refTop: number, stack: {dom: HTMLElement, top: number, left: number}[] } { let rect = view.dom.getBoundingClientRect(), startY = Math.max(0, rect.top) let refDOM: HTMLElement, refTop: number for (let x = (rect.left + rect.right) / 2, y = startY + 1; y < Math.min(innerHeight, rect.bottom); y += 5) { let dom = view.root.elementFromPoint(x, y) if (!dom || dom == view.dom || !view.dom.contains(dom)) continue let localRect = dom.getBoundingClientRect() if (localRect.top >= startY - 20) { refDOM = dom as HTMLElement refTop = localRect.top break } } return {refDOM: refDOM!, refTop: refTop!, stack: scrollStack(view.dom)} } function scrollStack(dom: Node): {dom: HTMLElement, top: number, left: number}[] { let stack = [], doc = dom.ownerDocument for (let cur: Node | null = dom; cur; cur = parentNode(cur)) { stack.push({dom: cur as HTMLElement, top: (cur as HTMLElement).scrollTop, left: (cur as HTMLElement).scrollLeft}) if (dom == doc) break } return stack } // Reset the scroll position of the editor's parent nodes to that what // it was before, when storeScrollPos was called. export function resetScrollPos({refDOM, refTop, stack}: { refDOM: HTMLElement, refTop: number, stack: {dom: HTMLElement, top: number, left: number}[] }) { let newRefTop = refDOM ? refDOM.getBoundingClientRect().top : 0 restoreScrollStack(stack, newRefTop == 0 ? 0 : newRefTop - refTop) } function restoreScrollStack(stack: {dom: HTMLElement, top: number, left: number}[], dTop: number) { for (let i = 0; i < stack.length; i++) { let {dom, top, left} = stack[i] if (dom.scrollTop != top + dTop) dom.scrollTop = top + dTop if (dom.scrollLeft != left) dom.scrollLeft = left } } let preventScrollSupported: false | null | {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 stored = scrollStack(dom) dom.focus(preventScrollSupported == null ? { get preventScroll() { preventScrollSupported = {preventScroll: true} return true } } : undefined) if (!preventScrollSupported) { preventScrollSupported = false restoreScrollStack(stored, 0) } } function findOffsetInNode(node: HTMLElement, coords: {top: number, left: number}): {node: Node, offset: number} { let closest: Text | HTMLElement | undefined let dxClosest = 2e8, coordsClosest: {left: number, top: number} | undefined, offset = 0 let rowBot = coords.top, rowTop = coords.top let firstBelow: Text | HTMLElement | undefined, coordsBelow: {left: number, top: number} | undefined for (let child = node.firstChild, childIndex = 0; child; child = child.nextSibling, childIndex++) { let rects if (isElt(child)) rects = child.getClientRects() else if (isText(child)) rects = textRange(child).getClientRects() else continue for (let i = 0; i < rects.length; i++) { let rect = rects[i] if (rect.top <= rowBot && rect.bottom >= rowTop) { rowBot = Math.max(rect.bottom, rowBot) rowTop = Math.min(rect.top, rowTop) let dx = rect.left > coords.left ? rect.left - coords.left : rect.right < coords.left ? coords.left - rect.right : 0 if (dx < dxClosest) { closest = child dxClosest = dx coordsClosest = dx && isText(closest) ? { left: rect.right < coords.left ? rect.right : rect.left, top: coords.top } : coords if (isElt(child) && dx) offset = childIndex + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0) continue } } else if (rect.top > coords.top && !firstBelow && rect.left <= coords.left && rect.right >= coords.left) { firstBelow = child coordsBelow = {left: Math.max(rect.left, Math.min(rect.right, coords.left)), top: rect.top} } if (!closest && (coords.left >= rect.right && coords.top >= rect.top || coords.left >= rect.left && coords.top >= rect.bottom)) offset = childIndex + 1 } } if (!closest && firstBelow) { closest = firstBelow; coordsClosest = coordsBelow; dxClosest = 0 } if (closest && isText(closest)) return findOffsetInText(closest, coordsClosest!) if (!closest || dxClosest) return {node, offset} return findOffsetInNode(closest, coordsClosest!) } function findOffsetInText(node: Text, coords: {top: number, left: number}) { let len = node.nodeValue!.length let range = document.createRange(), result: {node: Node, offset: number} | undefined for (let i = 0; i < len; i++) { range.setEnd(node, i + 1) range.setStart(node, i) let rect = singleRect(range, 1) if (rect.top == rect.bottom) continue if (inRect(coords, rect)) { result = {node, offset: i + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0)} break } } range.detach() return result || {node, offset: 0} } function inRect(coords: {top: number, left: number}, rect: Rect) { return coords.left >= rect.left - 1 && coords.left <= rect.right + 1&& coords.top >= rect.top - 1 && coords.top <= rect.bottom + 1 } function targetKludge(dom: HTMLElement, coords: {top: number, left: number}) { let parent = dom.parentNode if (parent && /^li$/i.test(parent.nodeName) && coords.left < dom.getBoundingClientRect().left) return parent as HTMLElement return dom } function posFromElement(view: EditorView, elt: HTMLElement, coords: {top: number, left: number}) { let {node, offset} = findOffsetInNode(elt, coords), bias = -1 if (isElt(node) && !node.firstChild) { let rect = node.getBoundingClientRect() bias = rect.left != rect.right && coords.left > (rect.left + rect.right) / 2 ? 1 : -1 } return view.docView.posFromDOM(node, offset, bias) } function posFromCaret(view: EditorView, node: Node, offset: number, coords: {top: number, left: number}) { // Browser (in caretPosition/RangeFromPoint) will agressively // normalize towards nearby inline nodes. Since we are interested in // positions between block nodes too, we first walk up the hierarchy // of nodes to see if there are block nodes that the coordinates // fall outside of. If so, we take the position before/after that // block. If not, we call `posFromDOM` on the raw node/offset. let outsideBlock = -1 for (let cur = node, sawBlock = false;;) { if (cur == view.dom) break let desc = view.docView.nearestDesc(cur, true), rect if (!desc) return null if (isElt(desc.dom) && (desc.node.isBlock && desc.parent || !desc.contentDOM) && // Ignore elements with zero-size bounding rectangles ((rect = desc.dom.getBoundingClientRect()).width || rect.height)) { if (desc.node.isBlock && desc.parent && !/^T(R|BODY|HEAD|FOOT)$/.test(desc.dom!.nodeName)) { // Only apply the horizontal test to the innermost block. Vertical for any parent. if (!sawBlock && rect.left > coords.left || rect.top > coords.top) outsideBlock = desc.posBefore else if (!sawBlock && rect.right < coords.left || rect.bottom < coords.top) outsideBlock = desc.posAfter sawBlock = true } if (!desc.contentDOM && outsideBlock < 0 && !desc.node.isText) { // If we are inside a leaf, return the side of the leaf closer to the coords let before = desc.node.isBlock ? coords.top < (rect.top + rect.bottom) / 2 : coords.left < (rect.left + rect.right) / 2 return before ? desc.posBefore : desc.posAfter } } cur = desc.dom.parentNode! } return outsideBlock > -1 ? outsideBlock : view.docView.posFromDOM(node, offset, -1) } function elementFromPoint(element: HTMLElement, coords: {top: number, left: number}, box: Rect): HTMLElement { let len = element.childNodes.length if (len && box.top < box.bottom) { for (let startI = Math.max(0, Math.min(len - 1, Math.floor(len * (coords.top - box.top) / (box.bottom - box.top)) - 2)), i = startI;;) { let child = element.childNodes[i] if (isElt(child)) { let rects = child.getClientRects() for (let j = 0; j < rects.length; j++) { let rect = rects[j] if (inRect(coords, rect)) return elementFromPoint(child, coords, rect) } } if ((i = (i + 1) % len) == startI) break } } return element } // Given an x,y position on the editor, get the position in the document. export function posAtCoords(view: EditorView, coords: {top: number, left: number}) { let doc = view.dom.ownerDocument, node: Node | undefined, offset = 0 let caret = caretFromPoint(doc, coords.left, coords.top) if (caret) ({node, offset} = caret) let elt = ((view.root as any).elementFromPoint ? view.root : doc) .elementFromPoint(coords.left, coords.top) as HTMLElement let pos if (!elt || !view.dom.contains(elt)) { let box = view.dom.getBoundingClientRect() if (!inRect(coords, box)) return null elt = elementFromPoint(view.dom, coords, box) if (!elt) return null } // Safari's caretRangeFromPoint returns nonsense when on a draggable element if (browser.safari) { for (let p: Node | null = elt; node && p; p = parentNode(p)) if ((p as HTMLElement).draggable) node = undefined } elt = targetKludge(elt, coords) if (node) { if (browser.gecko && isElt(node)) { // Firefox will sometimes return offsets into nodes, which // have no actual children, from caretPositionFromPoint (#953) offset = Math.min(offset, node.childNodes.length) // It'll also move the returned position before image nodes, // even if those are behind it. if (offset < node.childNodes.length) { let next = node.childNodes[offset], box if (next.nodeName == "IMG" && (box = (next as HTMLElement).getBoundingClientRect()).right <= coords.left && box.bottom > coords.top) offset++ } } let prev // When clicking above the right side of an uneditable node, Chrome will report a cursor position after that node. if (browser.webkit && offset && isElt(node) && isElt(prev = node.childNodes[offset - 1]) && prev.contentEditable == "false" && prev.getBoundingClientRect().top >= coords.top) offset-- // Suspiciously specific kludge to work around caret*FromPoint // never returning a position at the end of the document if (node == view.dom && offset == node.childNodes.length - 1 && isElt(node.lastChild!) && coords.top > node.lastChild.getBoundingClientRect().bottom) pos = view.state.doc.content.size // Ignore positions directly after a BR, since caret*FromPoint // 'round up' positions that would be more accurately placed // before the BR node. else if (offset == 0 || !isElt(node) || node.childNodes[offset - 1].nodeName != "BR") pos = posFromCaret(view, node, offset, coords) } if (pos == null) pos = posFromElement(view, elt, coords) let desc = view.docView.nearestDesc(elt, true) return {pos, inside: desc ? desc.posAtStart - desc.border : -1} } function nonZero(rect: Rect) { return rect.top < rect.bottom || rect.left < rect.right } function singleRect(target: HTMLElement | Range, bias: number): DOMRect { let rects = target.getClientRects() if (rects.length) { let first = rects[bias < 0 ? 0 : rects.length - 1] if (nonZero(first)) return first } return Array.prototype.find.call(rects, nonZero) || target.getBoundingClientRect() } const BIDI = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/ // Given a position in the document model, get a bounding box of the // character at that position, relative to the window. export function coordsAtPos(view: EditorView, pos: number, side: number): Rect { let {node, offset, atom} = view.docView.domFromPos(pos, side < 0 ? -1 : 1) let supportEmptyRange = browser.webkit || browser.gecko if (isText(node)) { // These browsers support querying empty text ranges. Prefer that in // bidi context or when at the end of a node. if (supportEmptyRange && (BIDI.test(node.nodeValue!) || (side < 0 ? !offset : offset == node.nodeValue!.length))) { let rect = singleRect(textRange(node, offset, offset), side) // Firefox returns bad results (the position before the space) // when querying a position directly after line-broken // whitespace. Detect this situation and and kludge around it if (browser.gecko && offset && /\s/.test(node.nodeValue![offset - 1]) && offset < node.nodeValue!.length) { let rectBefore = singleRect(textRange(node, offset - 1, offset - 1), -1) if (rectBefore.top == rect.top) { let rectAfter = singleRect(textRange(node, offset, offset + 1), -1) if (rectAfter.top != rect.top) return flattenV(rectAfter, rectAfter.left < rectBefore.left) } } return rect } else { let from = offset, to = offset, takeSide = side < 0 ? 1 : -1 if (side < 0 && !offset) { to++; takeSide = -1 } else if (side >= 0 && offset == node.nodeValue!.length) { from--; takeSide = 1 } else if (side < 0) { from-- } else { to ++ } return flattenV(singleRect(textRange(node, from, to), takeSide), takeSide < 0) } } let $dom = view.state.doc.resolve(pos - (atom || 0)) // Return a horizontal line in block context if (!$dom.parent.inlineContent) { if (atom == null && offset && (side < 0 || offset == nodeSize(node))) { let before = node.childNodes[offset - 1] if (isElt(before)) return flattenH(before.getBoundingClientRect(), false) } if (atom == null && offset < nodeSize(node)) { let after = node.childNodes[offset] if (isElt(after)) return flattenH(after.getBoundingClientRect(), true) } return flattenH((node as HTMLElement).getBoundingClientRect(), side >= 0) } // Inline, not in text node (this is not Bidi-safe) if (atom == null && offset && (side < 0 || offset == nodeSize(node))) { let before = node.childNodes[offset - 1] let target = isText(before) ? textRange(before, nodeSize(before) - (supportEmptyRange ? 0 : 1)) // BR nodes tend to only return the rectangle before them. // Only use them if they are the last element in their parent : isElt(before) && (before.nodeName != "BR" || !before.nextSibling) ? before : null if (target) return flattenV(singleRect(target, 1), false) } if (atom == null && offset < nodeSize(node)) { let after = node.childNodes[offset] while (after.pmViewDesc && after.pmViewDesc.ignoreForCoords) after = after.nextSibling! let target = !after ? null : isText(after) ? textRange(after, 0, (supportEmptyRange ? 0 : 1)) : isElt(after) ? after : null if (target) return flattenV(singleRect(target, -1), true) } // All else failed, just try to get a rectangle for the target node return flattenV(singleRect(isText(node) ? textRange(node) : node as HTMLElement, -side), side >= 0) } function flattenV(rect: DOMRect, left: boolean) { if (rect.width == 0) return rect let x = left ? rect.left : rect.right return {top: rect.top, bottom: rect.bottom, left: x, right: x} } function flattenH(rect: DOMRect, top: boolean) { if (rect.height == 0) return rect let y = top ? rect.top : rect.bottom return {top: y, bottom: y, left: rect.left, right: rect.right} } function withFlushedState(view: EditorView, state: EditorState, f: () => T): T { let viewState = view.state, active = view.root.activeElement as HTMLElement if (viewState != state) view.updateState(state) if (active != view.dom) view.focus() try { return f() } finally { if (viewState != state) view.updateState(viewState) if (active != view.dom && active) active.focus() } } // Whether vertical position motion in a given direction // from a position would leave a text block. function endOfTextblockVertical(view: EditorView, state: EditorState, dir: "up" | "down") { let sel = state.selection let $pos = dir == "up" ? sel.$from : sel.$to return withFlushedState(view, state, () => { let {node: dom} = view.docView.domFromPos($pos.pos, dir == "up" ? -1 : 1) for (;;) { let nearest = view.docView.nearestDesc(dom, true) if (!nearest) break if (nearest.node.isBlock) { dom = nearest.contentDOM || nearest.dom; break } dom = nearest.dom.parentNode! } let coords = coordsAtPos(view, $pos.pos, 1) for (let child = dom.firstChild; child; child = child.nextSibling) { let boxes if (isElt(child)) boxes = child.getClientRects() else if (isText(child)) boxes = textRange(child, 0, child.nodeValue!.length).getClientRects() else continue for (let i = 0; i < boxes.length; i++) { let box = boxes[i] if (box.bottom > box.top + 1 && (dir == "up" ? coords.top - box.top > (box.bottom - coords.top) * 2 : box.bottom - coords.bottom > (coords.bottom - box.top) * 2)) return false } } return true }) } const maybeRTL = /[\u0590-\u08ac]/ function endOfTextblockHorizontal(view: EditorView, state: EditorState, dir: "left" | "right" | "forward" | "backward") { let {$head} = state.selection if (!$head.parent.isTextblock) return false let offset = $head.parentOffset, atStart = !offset, atEnd = offset == $head.parent.content.size let sel: Selection = view.domSelection()! if (!sel) return $head.pos == $head.start() || $head.pos == $head.end() // If the textblock is all LTR, or the browser doesn't support // Selection.modify (Edge), fall back to a primitive approach if (!maybeRTL.test($head.parent.textContent) || !(sel as any).modify) return dir == "left" || dir == "backward" ? atStart : atEnd return withFlushedState(view, state, () => { // This is a huge hack, but appears to be the best we can // currently do: use `Selection.modify` to move the selection by // one character, and see if that moves the cursor out of the // textblock (or doesn't move it at all, when at the start/end of // the document). let {focusNode: oldNode, focusOffset: oldOff, anchorNode, anchorOffset} = view.domSelectionRange() let oldBidiLevel = (sel as any).caretBidiLevel // Only for Firefox ;(sel as any).modify("move", dir, "character") let parentDOM = $head.depth ? view.docView.domAfterPos($head.before()) : view.dom let {focusNode: newNode, focusOffset: newOff} = view.domSelectionRange() let result = newNode && !contains(parentDOM, newNode) || (oldNode == newNode && oldOff == newOff) // Restore the previous selection try { sel.collapse(anchorNode, anchorOffset) if (oldNode && (oldNode != anchorNode || oldOff != anchorOffset) && sel.extend) sel.extend(oldNode, oldOff) } catch (_) {} if (oldBidiLevel != null) (sel as any).caretBidiLevel = oldBidiLevel return result }) } export type TextblockDir = "up" | "down" | "left" | "right" | "forward" | "backward" let cachedState: EditorState | null = null let cachedDir: TextblockDir | null = null let cachedResult: boolean = false export function endOfTextblock(view: EditorView, state: EditorState, dir: TextblockDir) { if (cachedState == state && cachedDir == dir) return cachedResult cachedState = state; cachedDir = dir return cachedResult = dir == "up" || dir == "down" ? endOfTextblockVertical(view, state, dir) : endOfTextblockHorizontal(view, state, dir) } prosemirror-view/src/domobserver.ts000066400000000000000000000366571527132543100201230ustar00rootroot00000000000000import {Selection} from "prosemirror-state" import * as browser from "./browser" import {domIndex, isEquivalentPosition, selectionCollapsed, parentNode, DOMSelectionRange, DOMNode, DOMSelection, isText, isElt} from "./dom" import {hasFocusAndSelection, selectionToDOM, selectionFromDOM} from "./selection" import {EditorView} from "./index" const observeOptions = { childList: true, characterData: true, characterDataOldValue: true, attributes: true, attributeOldValue: true, subtree: true } // IE11 has very broken mutation observers, so we also listen to DOMCharacterDataModified const useCharData = browser.ie && browser.ie_version <= 11 class SelectionState { anchorNode: Node | null = null anchorOffset: number = 0 focusNode: Node | null = null focusOffset: number = 0 set(sel: DOMSelectionRange) { this.anchorNode = sel.anchorNode; this.anchorOffset = sel.anchorOffset this.focusNode = sel.focusNode; this.focusOffset = sel.focusOffset } clear() { this.anchorNode = this.focusNode = null } eq(sel: DOMSelectionRange) { return sel.anchorNode == this.anchorNode && sel.anchorOffset == this.anchorOffset && sel.focusNode == this.focusNode && sel.focusOffset == this.focusOffset } } export class DOMObserver { queue: MutationRecord[] = [] flushingSoon = -1 observer: MutationObserver | null = null currentSelection = new SelectionState onCharData: ((e: Event) => void) | null = null suppressingSelectionUpdates = false lastChangedTextNode: Text | null = null constructor( readonly view: EditorView, readonly handleDOMChange: (from: number, to: number, typeOver: boolean, added: Node[]) => void ) { this.observer = window.MutationObserver && new window.MutationObserver(mutations => { for (let i = 0; i < mutations.length; i++) this.queue.push(mutations[i]) if (browser.ie && browser.ie_version <= 11 && mutations.some( m => m.type == "childList" && m.removedNodes.length || m.type == "characterData" && m.oldValue!.length > m.target.nodeValue!.length)) { // IE11 will sometimes (on backspacing out a single character // text node after a BR node) call the observer callback // before actually updating the DOM, which will cause // ProseMirror to miss the change (see #930) this.flushSoon() } else if (browser.safari && view.composing && mutations.some( m => m.type == "childList" && m.target.nodeName == "TR")) { // Safari does weird stuff when finishing a composition in a // table cell, which tends to involve inserting inappropriate // nodes in the table row. view.input.badSafariComposition = true this.flushSoon() } else { this.flush() } }) if (useCharData) { this.onCharData = e => { this.queue.push({target: e.target as Node, type: "characterData", oldValue: (e as any).prevValue} as MutationRecord) this.flushSoon() } } this.onSelectionChange = this.onSelectionChange.bind(this) } flushSoon() { if (this.flushingSoon < 0) this.flushingSoon = window.setTimeout(() => { this.flushingSoon = -1; this.flush() }, 20) } forceFlush() { if (this.flushingSoon > -1) { window.clearTimeout(this.flushingSoon) this.flushingSoon = -1 this.flush() } } start() { if (this.observer) { this.observer.takeRecords() this.observer.observe(this.view.dom, observeOptions) } if (this.onCharData) this.view.dom.addEventListener("DOMCharacterDataModified", this.onCharData) this.connectSelection() } stop() { if (this.observer) { let take = this.observer.takeRecords() if (take.length) { for (let i = 0; i < take.length; i++) this.queue.push(take[i]) window.setTimeout(() => this.flush(), 20) } this.observer.disconnect() } if (this.onCharData) this.view.dom.removeEventListener("DOMCharacterDataModified", this.onCharData) this.disconnectSelection() } connectSelection() { this.view.dom.ownerDocument.addEventListener("selectionchange", this.onSelectionChange) } disconnectSelection() { this.view.dom.ownerDocument.removeEventListener("selectionchange", this.onSelectionChange) } suppressSelectionUpdates() { this.suppressingSelectionUpdates = true setTimeout(() => this.suppressingSelectionUpdates = false, 50) } onSelectionChange() { if (!hasFocusAndSelection(this.view)) return if (this.suppressingSelectionUpdates) return selectionToDOM(this.view) // Deletions on IE11 fire their events in the wrong order, giving // us a selection change event before the DOM changes are // reported. if (browser.ie && browser.ie_version <= 11 && !this.view.state.selection.empty) { let sel = this.view.domSelectionRange() // Selection.isCollapsed isn't reliable on IE if (sel.focusNode && isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode!, sel.anchorOffset)) return this.flushSoon() } this.flush() } setCurSelection() { this.currentSelection.set(this.view.domSelectionRange()) } ignoreSelectionChange(sel: DOMSelectionRange) { if (!sel.focusNode) return true let ancestors: Set = new Set, container: DOMNode | undefined for (let scan: DOMNode | null = sel.focusNode; scan; scan = parentNode(scan)) ancestors.add(scan) for (let scan = sel.anchorNode; scan; scan = parentNode(scan)) if (ancestors.has(scan)) { container = scan break } let desc = container && this.view.docView.nearestDesc(container) if (desc && desc.ignoreMutation({ type: "selection", target: isText(container!) ? container!.parentNode! : container! })) { this.setCurSelection() return true } } pendingRecords() { if (this.observer) for (let mut of this.observer.takeRecords()) this.queue.push(mut) return this.queue } flush() { let {view} = this if (!view.docView || this.flushingSoon > -1) return let mutations = this.pendingRecords() if (mutations.length) this.queue = [] let selRange: DOMSelectionRange | undefined, newSel = false if (!this.suppressingSelectionUpdates && hasFocusAndSelection(view)) { selRange = view.domSelectionRange() newSel = !this.currentSelection.eq(selRange) && !this.ignoreSelectionChange(selRange) } let from = -1, to = -1, typeOver = false, added: Node[] = [] if (view.editable) { for (let i = 0; i < mutations.length; i++) { let result = this.registerMutation(mutations[i], added) if (result) { from = from < 0 ? result.from : Math.min(result.from, from) to = to < 0 ? result.to : Math.max(result.to, to) if (result.typeOver) typeOver = true } } } if (added.some(n => n.nodeName == "BR") && (view.input.lastKeyCode == 8 || view.input.lastKeyCode == 46 || browser.chrome && (view.composing || view.input.compositionEndedAt > Date.now() - 50) && mutations.some(m => m.type == "childList" && m.removedNodes.length))) { // Browsers sometimes insert a bogus break node if you // backspace out the last bit of text before an inline-flex node (#1552) for (let node of added) if (node.nodeName == "BR" && node.parentNode) { let after = node.nextSibling while (after && isElt(after)) { if (after.contentEditable == "false") { node.parentNode.removeChild(node) break } after = after.firstChild } } } else if (browser.gecko && added.length) { let brs = added.filter(n => n.nodeName == "BR") as HTMLElement[] if (brs.length == 2) { let [a, b] = brs if (a.parentNode && a.parentNode.parentNode == b.parentNode) b.remove() else a.remove() } else { let {focusNode} = this.currentSelection for (let br of brs) { let parent = br.parentNode if (parent && parent.nodeName == "LI" && (!focusNode || blockParent(view, focusNode) != parent)) br.remove() } } } let readSel: Selection | null = null // If it looks like the browser has reset the selection to the // start of the document after focus, restore the selection from // the state if (from < 0 && newSel && view.input.lastFocus > Date.now() - 200 && Math.max(view.input.lastTouch, view.input.lastClick.time) < Date.now() - 300 && selectionCollapsed(selRange!) && (readSel = selectionFromDOM(view)) && readSel.eq(Selection.near(view.state.doc.resolve(0), 1))) { view.input.lastFocus = 0 selectionToDOM(view) this.currentSelection.set(selRange!) view.scrollToSelection() } else if (from > -1 || newSel) { if (from > -1) { view.docView.markDirty(from, to) checkCSS(view) } if (view.input.badSafariComposition) { view.input.badSafariComposition = false fixUpBadSafariComposition(view, added) } this.handleDOMChange(from, to, typeOver, added) if (view.docView && view.docView.dirty) view.updateState(view.state) else if (selRange && !this.currentSelection.eq(selRange)) selectionToDOM(view) if (selRange) this.currentSelection.set(selRange) } } registerMutation(mut: MutationRecord, added: Node[]) { // Ignore mutations inside nodes that were already noted as inserted if (added.indexOf(mut.target) > -1) return null let desc = this.view.docView.nearestDesc(mut.target) if (mut.type == "attributes" && (desc == this.view.docView || mut.attributeName == "contenteditable" || // Firefox sometimes fires spurious events for null/empty styles (mut.attributeName == "style" && !mut.oldValue && !(mut.target as HTMLElement).getAttribute("style")))) return null if (!desc || desc.ignoreMutation(mut)) return null if (mut.type == "childList") { for (let i = 0; i < mut.addedNodes.length; i++) { let node = mut.addedNodes[i] added.push(node) if (isText(node)) this.lastChangedTextNode = node } if (desc.contentDOM && desc.contentDOM != desc.dom && !desc.contentDOM.contains(mut.target)) return {from: desc.posBefore, to: desc.posAfter} let prev = mut.previousSibling, next = mut.nextSibling if (browser.ie && browser.ie_version <= 11 && mut.addedNodes.length) { // IE11 gives us incorrect next/prev siblings for some // insertions, so if there are added nodes, recompute those for (let i = 0; i < mut.addedNodes.length; i++) { let {previousSibling, nextSibling} = mut.addedNodes[i] if (!previousSibling || Array.prototype.indexOf.call(mut.addedNodes, previousSibling) < 0) prev = previousSibling if (!nextSibling || Array.prototype.indexOf.call(mut.addedNodes, nextSibling) < 0) next = nextSibling } } let fromOffset = prev && prev.parentNode == mut.target ? domIndex(prev) + 1 : 0 let from = desc.localPosFromDOM(mut.target, fromOffset, -1) let toOffset = next && next.parentNode == mut.target ? domIndex(next) : mut.target.childNodes.length let to = desc.localPosFromDOM(mut.target, toOffset, 1) return {from, to} } else if (mut.type == "attributes") { return {from: desc.posAtStart - desc.border, to: desc.posAtEnd + desc.border} } else { // "characterData" this.lastChangedTextNode = mut.target as Text return { from: desc.posAtStart, to: desc.posAtEnd, // An event was generated for a text change that didn't change // any text. Mark the dom change to fall back to assuming the // selection was typed over with an identical value if it can't // find another change. typeOver: mut.target.nodeValue == mut.oldValue } } } } let cssChecked: WeakMap = new WeakMap() let cssCheckWarned: boolean = false function checkCSS(view: EditorView) { if (cssChecked.has(view)) return cssChecked.set(view, null) if (['normal', 'nowrap', 'pre-line'].indexOf(getComputedStyle(view.dom).whiteSpace) !== -1) { view.requiresGeckoHackNode = browser.gecko if (cssCheckWarned) return console["warn"]("ProseMirror expects the CSS white-space property to be set, preferably to 'pre-wrap'. It is recommended to load style/prosemirror.css from the prosemirror-view package.") cssCheckWarned = true } } function rangeToSelectionRange(view: EditorView, range: StaticRange) { let anchorNode = range.startContainer, anchorOffset = range.startOffset let focusNode = range.endContainer, focusOffset = range.endOffset let currentAnchor = view.domAtPos(view.state.selection.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(currentAnchor.node, currentAnchor.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 // Based on https://github.com/codemirror/dev/issues/414 fix export function safariShadowSelectionRange(view: EditorView, selection: DOMSelection): DOMSelectionRange | null { if ((selection as any).getComposedRanges) { let range = (selection as any).getComposedRanges(view.root)[0] as StaticRange if (range) return rangeToSelectionRange(view, range) } let found: StaticRange | undefined function read(event: InputEvent) { event.preventDefault() event.stopImmediatePropagation() found = event.getTargetRanges()[0] } // Because Safari (at least in 2018-2022) 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. view.dom.addEventListener("beforeinput", read, true) document.execCommand("indent") view.dom.removeEventListener("beforeinput", read, true) return found ? rangeToSelectionRange(view, found) : null } function blockParent(view: EditorView, node: DOMNode): Node | null { for (let p = node.parentNode; p && p != view.dom; p = p.parentNode) { let desc = view.docView.nearestDesc(p, true) if (desc && desc.node.isBlock) return p } return null } // Kludge for a Safari bug where, on ending a composition in an // otherwise empty table cell, it randomly moves the composed text // into the table row around that cell, greatly confusing everything // (#188). function fixUpBadSafariComposition(view: EditorView, addedNodes: readonly DOMNode[]) { let {focusNode, focusOffset} = view.domSelectionRange() for (let node of addedNodes) { if (node.parentNode?.nodeName == "TR") { let nextCell = node.nextSibling while (nextCell && (nextCell.nodeName != "TD" && nextCell.nodeName != "TH")) nextCell = nextCell.nextSibling if (nextCell) { let parent = nextCell for (;;) { let first = parent.firstChild if (!first || !isElt(first) || first.contentEditable == "false" || /^(BR|IMG)$/.test(first.nodeName)) break parent = first } parent.insertBefore(node, parent.firstChild) if (focusNode == node) view.domSelection()!.collapse(node, focusOffset) } else { node.parentNode.removeChild(node) } } } } prosemirror-view/src/index.ts000066400000000000000000001101351527132543100166630ustar00rootroot00000000000000import {NodeSelection, EditorState, Plugin, PluginView, Transaction, Selection} from "prosemirror-state" import {Slice, ResolvedPos, DOMParser, DOMSerializer, Node, Mark} from "prosemirror-model" import {scrollRectIntoView, posAtCoords, coordsAtPos, endOfTextblock, storeScrollPos, resetScrollPos, focusPreventScroll} from "./domcoords" import {docViewDesc, ViewDesc, NodeView, NodeViewDesc, MarkView} from "./viewdesc" import {initInput, destroyInput, dispatchEvent, ensureListeners, clearComposition, InputState, doPaste, Dragging, findCompositionNode} from "./input" import {selectionToDOM, anchorInRightPlace, syncNodeSelection} from "./selection" import {Decoration, viewDecorations, DecorationSource} from "./decoration" import {DOMObserver, safariShadowSelectionRange} from "./domobserver" import {readDOMChange} from "./domchange" import {DOMSelection, DOMNode, DOMSelectionRange, deepActiveElement, clearReusedRange, isElt, contains} from "./dom" import * as browser from "./browser" export {Decoration, DecorationSet, DecorationAttrs, DecorationSource} from "./decoration" export {NodeView, MarkView, ViewMutationRecord} from "./viewdesc" // Exported for testing import {serializeForClipboard, parseFromClipboard} from "./clipboard" import {endComposition} from "./input" /// @internal export const __parseFromClipboard = parseFromClipboard /// @internal export const __endComposition = endComposition /// An editor view manages the DOM structure that represents an /// editable document. Its state and behavior are determined by its /// [props](#view.DirectEditorProps). export class EditorView { /// @internal _props: DirectEditorProps private directPlugins: readonly Plugin[] private _root: Document | ShadowRoot | null = null /// @internal focused = false /// Kludge used to work around a Chrome bug @internal trackWrites: DOMNode | null = null private mounted = false /// @internal markCursor: readonly Mark[] | null = null /// @internal cursorWrapper: {dom: DOMNode, deco: Decoration} | null = null /// @internal nodeViews: NodeViewSet /// @internal lastSelectedViewDesc: ViewDesc | undefined = undefined /// @internal docView: NodeViewDesc /// @internal input = new InputState private prevDirectPlugins: readonly Plugin[] = [] private pluginViews: PluginView[] = [] /// @internal declare domObserver: DOMObserver /// Holds `true` when a hack node is needed in Firefox to prevent the /// [space is eaten issue](https://code.haverbeke.berlin/prosemirror/prosemirror/issues/651) /// @internal requiresGeckoHackNode: boolean = false /// The view's current [state](#state.EditorState). public state: EditorState /// Create a view. `place` may be a DOM node that the editor should /// be appended to, a function that will place it into the document, /// or an object whose `mount` property holds the node to use as the /// document container. If it is `null`, the editor will not be /// added to the document. constructor(place: null | DOMNode | ((editor: HTMLElement) => void) | {mount: HTMLElement}, props: DirectEditorProps) { this._props = props this.state = props.state this.directPlugins = props.plugins || [] this.directPlugins.forEach(checkStateComponent) this.dispatch = this.dispatch.bind(this) this.dom = (place && (place as {mount: HTMLElement}).mount) || document.createElement("div") if (place) { if ((place as DOMNode).appendChild) (place as DOMNode).appendChild(this.dom) else if (typeof place == "function") place(this.dom) else if ((place as {mount: HTMLElement}).mount) this.mounted = true } this.editable = getEditable(this) updateCursorWrapper(this) this.nodeViews = buildNodeViews(this) this.docView = docViewDesc(this.state.doc, computeDocDeco(this), viewDecorations(this), this.dom, this) this.domObserver = new DOMObserver(this, (from, to, typeOver, added) => readDOMChange(this, from, to, typeOver, added)) this.domObserver.start() initInput(this) this.updatePluginViews() } /// An editable DOM node containing the document. (You probably /// should not directly interfere with its content.) readonly dom: HTMLElement /// Indicates whether the editor is currently [editable](#view.EditorProps.editable). editable: boolean /// When editor content is being dragged, this object contains /// information about the dragged slice and whether it is being /// copied or moved. At any other time, it is null. dragging: null | {slice: Slice, move: boolean} = null /// Holds `true` when a /// [composition](https://w3c.github.io/uievents/#events-compositionevents) /// is active. get composing() { return this.input.composing } /// The view's current [props](#view.EditorProps). get props() { if (this._props.state != this.state) { let prev = this._props this._props = {} as any for (let name in prev) (this._props as any)[name] = (prev as any)[name] this._props.state = this.state } return this._props } /// Update the view's props. Will immediately cause an update to /// the DOM. update(props: DirectEditorProps) { if (props.handleDOMEvents != this._props.handleDOMEvents) ensureListeners(this) let prevProps = this._props this._props = props if (props.plugins) { props.plugins.forEach(checkStateComponent) this.directPlugins = props.plugins } this.updateStateInner(props.state, prevProps) } /// Update the view by updating existing props object with the object /// given as argument. Equivalent to `view.update(Object.assign({}, /// view.props, props))`. setProps(props: Partial) { let updated = {} as DirectEditorProps for (let name in this._props) (updated as any)[name] = (this._props as any)[name] updated.state = this.state for (let name in props) (updated as any)[name] = (props as any)[name] this.update(updated) } /// Update the editor's `state` prop, without touching any of the /// other props. updateState(state: EditorState) { this.updateStateInner(state, this._props) } private updateStateInner(state: EditorState, prevProps: DirectEditorProps) { let prev = this.state, redraw = false, updateSel = false // When stored marks are added, stop composition, so that they can // be displayed. if (state.storedMarks && this.composing) { clearComposition(this) updateSel = true } this.state = state let pluginsChanged = prev.plugins != state.plugins || this._props.plugins != prevProps.plugins if (pluginsChanged || this._props.plugins != prevProps.plugins || this._props.nodeViews != prevProps.nodeViews) { let nodeViews = buildNodeViews(this) if (changedNodeViews(nodeViews, this.nodeViews)) { this.nodeViews = nodeViews redraw = true } } if (pluginsChanged || prevProps.handleDOMEvents != this._props.handleDOMEvents) { ensureListeners(this) } this.editable = getEditable(this) updateCursorWrapper(this) let innerDeco = viewDecorations(this), outerDeco = computeDocDeco(this) let scroll = prev.plugins != state.plugins && !prev.doc.eq(state.doc) ? "reset" : (state as any).scrollToSelection > (prev as any).scrollToSelection ? "to selection" : "preserve" let updateDoc = redraw || !this.docView.matchesNode(state.doc, outerDeco, innerDeco) if (updateDoc || !state.selection.eq(prev.selection)) updateSel = true let oldScrollPos = scroll == "preserve" && updateSel && this.dom.style.overflowAnchor == null && storeScrollPos(this) if (updateSel) { this.domObserver.stop() // Work around an issue in Chrome, IE, and Edge where changing // the DOM around an active selection puts it into a broken // state where the thing the user sees differs from the // selection reported by the Selection object (#710, #973, // #1011, #1013, #1035). let forceSelUpdate = updateDoc && (browser.ie || browser.chrome) && !this.composing && !prev.selection.empty && !state.selection.empty && selectionContextChanged(prev.selection, state.selection) if (updateDoc) { // If the node that the selection points into is written to, // Chrome sometimes starts misreporting the selection, so this // tracks that and forces a selection reset when our update // did write to the node. let chromeKludge = browser.chrome ? (this.trackWrites = this.domSelectionRange().focusNode) : null if (this.composing) this.input.compositionNode = findCompositionNode(this) if (redraw || !this.docView.update(state.doc, outerDeco, innerDeco, this)) { this.docView.updateOuterDeco(outerDeco) this.docView.destroy() this.docView = docViewDesc(state.doc, outerDeco, innerDeco, this.dom, this) } if (chromeKludge && (!this.trackWrites || !this.dom.contains(this.trackWrites))) forceSelUpdate = true } // Work around for an issue where an update arriving right between // a DOM selection change and the "selectionchange" event for it // can cause a spurious DOM selection update, disrupting mouse // drag selection. let mouseDown = this.input.mouseDown if (forceSelUpdate || !(mouseDown && this.domObserver.currentSelection.eq(this.domSelectionRange()) && anchorInRightPlace(this) && mouseDown.delaySelUpdate())) { selectionToDOM(this, forceSelUpdate) } else { syncNodeSelection(this, state.selection) this.domObserver.setCurSelection() } this.domObserver.start() } this.updatePluginViews(prev) if ((this.dragging as Dragging)?.node && !prev.doc.eq(state.doc)) this.updateDraggedNode(this.dragging as Dragging, prev) if (scroll == "reset") { this.dom.scrollTop = 0 } else if (scroll == "to selection") { this.scrollToSelection() } else if (oldScrollPos) { resetScrollPos(oldScrollPos) } } /// @internal scrollToSelection() { let startDOM = this.domSelectionRange().focusNode if (!startDOM || !contains(this.dom, startDOM)) { // Ignore selections outside the editor } else if (this.someProp("handleScrollToSelection", f => f(this))) { // Handled } else if (this.state.selection instanceof NodeSelection) { let target = this.docView.domAfterPos(this.state.selection.from) if (isElt(target)) scrollRectIntoView(this, target.getBoundingClientRect(), startDOM) } else { scrollRectIntoView(this, this.coordsAtPos(this.state.selection.head, 1), startDOM) } } private destroyPluginViews() { let view while (view = this.pluginViews.pop()) if (view.destroy) view.destroy() } private updatePluginViews(prevState?: EditorState) { if (!prevState || prevState.plugins != this.state.plugins || this.directPlugins != this.prevDirectPlugins) { this.prevDirectPlugins = this.directPlugins this.destroyPluginViews() for (let i = 0; i < this.directPlugins.length; i++) { let plugin = this.directPlugins[i] if (plugin.spec.view) this.pluginViews.push(plugin.spec.view(this)) } for (let i = 0; i < this.state.plugins.length; i++) { let plugin = this.state.plugins[i] if (plugin.spec.view) this.pluginViews.push(plugin.spec.view(this)) } } else { for (let i = 0; i < this.pluginViews.length; i++) { let pluginView = this.pluginViews[i] if (pluginView.update) pluginView.update(this, prevState) } } } private updateDraggedNode(dragging: Dragging, prev: EditorState) { let sel = dragging.node!, found = -1 if (sel.from < this.state.doc.content.size && this.state.doc.nodeAt(sel.from) == sel.node) { found = sel.from } else { let movedPos = sel.from + (this.state.doc.content.size - prev.doc.content.size) let moved = movedPos > 0 && movedPos < this.state.doc.content.size && this.state.doc.nodeAt(movedPos) if (moved == sel.node) found = movedPos } this.dragging = new Dragging(dragging.slice, dragging.move, found < 0 ? undefined : NodeSelection.create(this.state.doc, found)) } /// Goes over the values of a prop, first those provided directly, /// then those from plugins given to the view, then from plugins in /// the state (in order), and calls `f` every time a non-undefined /// value is found. When `f` returns a truthy value, that is /// immediately returned. When `f` isn't provided, it is treated as /// the identity function (the prop value is returned directly). someProp( propName: PropName, f: (value: NonNullable) => Result ): Result | undefined someProp(propName: PropName): NonNullable | undefined someProp( propName: PropName, f?: (value: NonNullable) => Result ): Result | undefined { let prop = this._props && this._props[propName], value if (prop != null && (value = f ? f(prop as any) : prop)) return value as any for (let i = 0; i < this.directPlugins.length; i++) { let prop = this.directPlugins[i].props[propName] if (prop != null && (value = f ? f(prop as any) : prop)) return value as any } let plugins = this.state.plugins if (plugins) for (let i = 0; i < plugins.length; i++) { let prop = plugins[i].props[propName] if (prop != null && (value = f ? f(prop as any) : prop)) return value as any } } /// Query whether the view has focus. hasFocus() { // Work around IE not handling focus correctly if resize handles are shown. // If the cursor is inside an element with resize handles, activeElement // will be that element instead of this.dom. if (browser.ie) { // If activeElement is within this.dom, and there are no other elements // setting `contenteditable` to false in between, treat it as focused. let node = this.root.activeElement if (node == this.dom) return true if (!node || !this.dom.contains(node)) return false while (node && this.dom != node && this.dom.contains(node)) { if ((node as HTMLElement).contentEditable == 'false') return false node = node.parentElement } return true } return this.root.activeElement == this.dom } /// Focus the editor. focus() { this.domObserver.stop() if (this.editable) focusPreventScroll(this.dom) selectionToDOM(this) this.domObserver.start() } /// Get the document root in which the editor exists. This will /// usually be the top-level `document`, but might be a [shadow /// DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Shadow_DOM) /// root if the editor is inside one. get root(): Document | ShadowRoot { let cached = this._root if (cached == null) for (let search = this.dom.parentNode; search; search = search.parentNode) { if (search.nodeType == 9 || (search.nodeType == 11 && (search as any).host)) { if (!(search as any).getSelection) Object.getPrototypeOf(search).getSelection = () => (search as DOMNode).ownerDocument!.getSelection() return this._root = search as Document | ShadowRoot } } return cached || document } /// When an existing editor view is moved to a new document or /// shadow tree, call this to make it recompute its root. updateRoot() { this._root = null } /// Given a pair of viewport coordinates, return the document /// position that corresponds to them. May return null if the given /// coordinates aren't inside of the editor. When an object is /// returned, its `pos` property is the position nearest to the /// coordinates, and its `inside` property holds the position of the /// inner node that the position falls inside of, or -1 if it is at /// the top level, not in any node. posAtCoords(coords: {left: number, top: number}): {pos: number, inside: number} | null { return posAtCoords(this, coords) } /// Returns the viewport rectangle at a given document position. /// `left` and `right` will be the same number, as this returns a /// flat cursor-ish rectangle. If the position is between two things /// that aren't directly adjacent, `side` determines which element /// is used. When < 0, the element before the position is used, /// otherwise the element after. coordsAtPos(pos: number, side = 1): {left: number, right: number, top: number, bottom: number} { return coordsAtPos(this, pos, side) } /// Find the DOM position that corresponds to the given document /// position. When `side` is negative, find the position as close as /// possible to the content before the position. When positive, /// prefer positions close to the content after the position. When /// zero, prefer as shallow a position as possible. /// /// Note that you should **not** mutate the editor's internal DOM, /// only inspect it (and even that is usually not necessary). domAtPos(pos: number, side = 0): {node: DOMNode, offset: number} { return this.docView.domFromPos(pos, side) } /// Find the DOM node that represents the document node after the /// given position. May return `null` when the position doesn't point /// in front of a node or if the node is inside an opaque node view. /// /// This is intended to be able to call things like /// `getBoundingClientRect` on that DOM node. Do **not** mutate the /// editor DOM directly, or add styling this way, since that will be /// immediately overriden by the editor as it redraws the node. nodeDOM(pos: number): DOMNode | null { let desc = this.docView.descAt(pos) return desc ? (desc as NodeViewDesc).nodeDOM : null } /// Find the document position that corresponds to a given DOM /// position. (Whenever possible, it is preferable to inspect the /// document structure directly, rather than poking around in the /// DOM, but sometimes—for example when interpreting an event /// target—you don't have a choice.) /// /// The `bias` parameter can be used to influence which side of a DOM /// node to use when the position is inside a leaf node. posAtDOM(node: DOMNode, offset: number, bias = -1): number { let pos = this.docView.posFromDOM(node, offset, bias) if (pos == null) throw new RangeError("DOM position not inside the editor") return pos } /// Find out whether the selection is at the end of a textblock when /// moving in a given direction. When, for example, given `"left"`, /// it will return true if moving left from the current cursor /// position would leave that position's parent textblock. Will apply /// to the view's current state by default, but it is possible to /// pass a different state. endOfTextblock(dir: "up" | "down" | "left" | "right" | "forward" | "backward", state?: EditorState): boolean { return endOfTextblock(this, state || this.state, dir) } /// Run the editor's paste logic with the given HTML string. The /// `event`, if given, will be passed to the /// [`handlePaste`](#view.EditorProps.handlePaste) hook. pasteHTML(html: string, event?: ClipboardEvent) { return doPaste(this, "", html, false, event || new ClipboardEvent("paste")) } /// Run the editor's paste logic with the given plain-text input. pasteText(text: string, event?: ClipboardEvent) { return doPaste(this, text, null, true, event || new ClipboardEvent("paste")) } /// Serialize the given slice as it would be if it was copied from /// this editor. Returns a DOM element that contains a /// representation of the slice as its children, a textual /// representation, and the transformed slice (which can be /// different from the given input due to hooks like /// [`transformCopied`](#view.EditorProps.transformCopied)). serializeForClipboard(slice: Slice): {dom: HTMLElement, text: string, slice: Slice} { return serializeForClipboard(this, slice) } /// Removes the editor from the DOM and destroys all [node /// views](#view.NodeView). destroy() { if (!this.docView) return destroyInput(this) this.destroyPluginViews() if (this.mounted) { this.docView.update(this.state.doc, [], viewDecorations(this), this) this.dom.textContent = "" } else if (this.dom.parentNode) { this.dom.parentNode.removeChild(this.dom) } this.docView.destroy() ;(this as any).docView = null clearReusedRange() } /// This is true when the view has been /// [destroyed](#view.EditorView.destroy) (and thus should not be /// used anymore). get isDestroyed() { return this.docView == null } /// Used for testing. dispatchEvent(event: Event) { return dispatchEvent(this, event) } /// Dispatch a transaction. Will call /// [`dispatchTransaction`](#view.DirectEditorProps.dispatchTransaction) /// when given, and otherwise defaults to applying the transaction to /// the current state and calling /// [`updateState`](#view.EditorView.updateState) with the result. /// This method is bound to the view instance, so that it can be /// easily passed around. declare dispatch: (tr: Transaction) => void /// @internal domSelectionRange(): DOMSelectionRange { let sel = this.dom.isConnected !== false && this.domSelection() if (!sel) return {focusNode: null, focusOffset: 0, anchorNode: null, anchorOffset: 0} return browser.safari && this.root.nodeType === 11 && deepActiveElement(this.dom.ownerDocument) == this.dom && safariShadowSelectionRange(this, sel) || sel } /// @internal domSelection(): DOMSelection | null { return (this.root as Document).getSelection() } } EditorView.prototype.dispatch = function(tr: Transaction) { let dispatchTransaction = this._props.dispatchTransaction if (dispatchTransaction) dispatchTransaction.call(this, tr) else this.updateState(this.state.apply(tr)) } function computeDocDeco(view: EditorView) { let attrs = Object.create(null) attrs.class = "ProseMirror" attrs.contenteditable = String(view.editable) view.someProp("attributes", value => { if (typeof value == "function") value = value(view.state) if (value) for (let attr in value) { if (attr == "class") attrs.class += " " + value[attr] else if (attr == "style") attrs.style = (attrs.style ? attrs.style + ";" : "") + value[attr] else if (!attrs[attr] && attr != "contenteditable" && attr != "nodeName") attrs[attr] = String(value[attr]) } }) if (!attrs.translate) attrs.translate = "no" return [Decoration.node(0, view.state.doc.content.size, attrs)] } function updateCursorWrapper(view: EditorView) { if (view.markCursor) { let dom = document.createElement("img") dom.className = "ProseMirror-separator" dom.setAttribute("mark-placeholder", "true") dom.setAttribute("alt", "") view.cursorWrapper = {dom, deco: Decoration.widget(view.state.selection.from, dom, {raw: true, marks: view.markCursor} as any)} } else { view.cursorWrapper = null } } function getEditable(view: EditorView) { return !view.someProp("editable", value => value(view.state) === false) } function selectionContextChanged(sel1: Selection, sel2: Selection) { let depth = Math.min(sel1.$anchor.sharedDepth(sel1.head), sel2.$anchor.sharedDepth(sel2.head)) return sel1.$anchor.start(depth) != sel2.$anchor.start(depth) } function buildNodeViews(view: EditorView) { let result: NodeViewSet = Object.create(null) function add(obj: NodeViewSet) { for (let prop in obj) if (!Object.prototype.hasOwnProperty.call(result, prop)) result[prop] = obj[prop] } view.someProp("nodeViews", add) view.someProp("markViews", add) return result } function changedNodeViews(a: NodeViewSet, b: NodeViewSet) { let nA = 0, nB = 0 for (let prop in a) { if (a[prop] != b[prop]) return true nA++ } for (let _ in b) nB++ return nA != nB } function checkStateComponent(plugin: Plugin) { if (plugin.spec.state || plugin.spec.filterTransaction || plugin.spec.appendTransaction) throw new RangeError("Plugins passed directly to the view must not have a state component") } /// The type of function [provided](#view.EditorProps.nodeViews) to /// create [node views](#view.NodeView). export type NodeViewConstructor = (node: Node, view: EditorView, getPos: () => number | undefined, decorations: readonly Decoration[], innerDecorations: DecorationSource) => NodeView /// The function types [used](#view.EditorProps.markViews) to create /// mark views. export type MarkViewConstructor = (mark: Mark, view: EditorView, inline: boolean) => MarkView type NodeViewSet = {[name: string]: NodeViewConstructor | MarkViewConstructor} /// Helper type that maps event names to event object types, but /// includes events that TypeScript's HTMLElementEventMap doesn't know /// about. export interface DOMEventMap extends HTMLElementEventMap { [event: string]: any } /// Props are configuration values that can be passed to an editor view /// or included in a plugin. This interface lists the supported props. /// /// The various event-handling functions may all return `true` to /// indicate that they handled the given event. The view will then take /// care to call `preventDefault` on the event, except with /// `handleDOMEvents`, where the handler itself is responsible for that. /// /// How a prop is resolved depends on the prop. Handler functions are /// called one at a time, starting with the base props and then /// searching through the plugins (in order of appearance) until one of /// them returns true. For some props, the first plugin that yields a /// value gets precedence. /// /// The optional type parameter refers to the type of `this` in prop /// functions, and is used to pass in the plugin type when defining a /// [plugin](#state.Plugin). export interface EditorProps

    { /// Can be an object mapping DOM event type names to functions that /// handle them. Such functions will be called before any handling /// ProseMirror does of events fired on the editable DOM element. /// Contrary to the other event handling props, when returning true /// from such a function, you are responsible for calling /// `preventDefault` yourself (or not, if you want to allow the /// default behavior). handleDOMEvents?: { [event in keyof DOMEventMap]?: (this: P, view: EditorView, event: DOMEventMap[event]) => boolean | void } /// Called when the editor receives a `keydown` event. handleKeyDown?: (this: P, view: EditorView, event: KeyboardEvent) => boolean | void /// Handler for `keypress` events. handleKeyPress?: (this: P, view: EditorView, event: KeyboardEvent) => boolean | void /// Whenever the user directly input text, this handler is called /// before the input is applied. If it returns `true`, the default /// behavior of actually inserting the text is suppressed. handleTextInput?: (this: P, view: EditorView, from: number, to: number, text: string, deflt: () => Transaction) => boolean | void /// Called for each node around a click, from the inside out. The /// `direct` flag will be true for the inner node. handleClickOn?: (this: P, view: EditorView, pos: number, node: Node, nodePos: number, event: MouseEvent, direct: boolean) => boolean | void /// Called when the editor is clicked, after `handleClickOn` handlers /// have been called. handleClick?: (this: P, view: EditorView, pos: number, event: MouseEvent) => boolean | void /// Called for each node around a double click. handleDoubleClickOn?: (this: P, view: EditorView, pos: number, node: Node, nodePos: number, event: MouseEvent, direct: boolean) => boolean | void /// Called when the editor is double-clicked, after `handleDoubleClickOn`. handleDoubleClick?: (this: P, view: EditorView, pos: number, event: MouseEvent) => boolean | void /// Called for each node around a triple click. handleTripleClickOn?: (this: P, view: EditorView, pos: number, node: Node, nodePos: number, event: MouseEvent, direct: boolean) => boolean | void /// Called when the editor is triple-clicked, after `handleTripleClickOn`. handleTripleClick?: (this: P, view: EditorView, pos: number, event: MouseEvent) => boolean | void /// Can be used to override the behavior of pasting. `slice` is the /// pasted content parsed by the editor, but you can directly access /// the event to get at the raw content. handlePaste?: (this: P, view: EditorView, event: ClipboardEvent, slice: Slice) => boolean | void /// Called when something is dropped on the editor. `moved` will be /// true if this drop moves from the current selection (which should /// thus be deleted). handleDrop?: (this: P, view: EditorView, event: DragEvent, slice: Slice, moved: boolean) => boolean | void /// Called when the view, after updating its state, tries to scroll /// the selection into view. A handler function may return false to /// indicate that it did not handle the scrolling and further /// handlers or the default behavior should be tried. handleScrollToSelection?: (this: P, view: EditorView) => boolean /// Determines whether an in-editor drag event should copy or move /// the selection. When not given, the event's `altKey` property is /// used on macOS, `ctrlKey` on other platforms. dragCopies?: (event: DragEvent) => boolean /// Can be used to override the way a selection is created when /// reading a DOM selection between the given anchor and head. createSelectionBetween?: (this: P, view: EditorView, anchor: ResolvedPos, head: ResolvedPos) => Selection | null /// The [parser](#model.DOMParser) to use when reading editor changes /// from the DOM. Defaults to calling /// [`DOMParser.fromSchema`](#model.DOMParser^fromSchema) on the /// editor's schema. domParser?: DOMParser /// Can be used to transform pasted HTML text, _before_ it is parsed, /// for example to clean it up. transformPastedHTML?: (this: P, html: string, view: EditorView) => string /// The [parser](#model.DOMParser) to use when reading content from /// the clipboard. When not given, the value of the /// [`domParser`](#view.EditorProps.domParser) prop is used. clipboardParser?: DOMParser /// Transform pasted plain text. The `plain` flag will be true when /// the text is pasted as plain text. transformPastedText?: (this: P, text: string, plain: boolean, view: EditorView) => string /// A function to parse text from the clipboard into a document /// slice. Called after /// [`transformPastedText`](#view.EditorProps.transformPastedText). /// The default behavior is to split the text into lines, wrap them /// in `


    ') ist(parseFromClipboard(view, "", dom.innerHTML, false, d.resolve(1)), d.slice((d as any).tag.a, (d as any).tag.a + 1), eq) }) it("includes context for text selections", () => { let d = doc(blockquote(ul(li(p("foo"), p("bar"))))) let view = tempEditor({doc: d}) let slice = TextSelection.create(d, (d as any).tag.a, (d as any).tag.b).content() let {dom, text} = view.serializeForClipboard(slice) ist(dom.innerHTML, '

    ` tags, and call /// [`clipboardParser`](#view.EditorProps.clipboardParser) on it. /// The `plain` flag will be true when the text is pasted as plain text. clipboardTextParser?: (this: P, text: string, $context: ResolvedPos, plain: boolean, view: EditorView) => Slice /// Can be used to transform pasted or dragged-and-dropped content /// before it is applied to the document. The `plain` flag will be /// true when the text is pasted as plain text. transformPasted?: (this: P, slice: Slice, view: EditorView, plain: boolean) => Slice /// Can be used to transform copied or cut content before it is /// serialized to the clipboard. transformCopied?: (this: P, slice: Slice, view: EditorView) => Slice /// Allows you to pass custom rendering and behavior logic for /// nodes. Should map node names to constructor functions that /// produce a [`NodeView`](#view.NodeView) object implementing the /// node's display behavior. The third argument `getPos` is a /// function that can be called to get the node's current position, /// which can be useful when creating transactions to update it. /// Note that if the node is not in the document, the position /// returned by this function will be `undefined`. /// /// `decorations` is an array of node or inline decorations that are /// active around the node. They are automatically drawn in the /// normal way, and you will usually just want to ignore this, but /// they can also be used as a way to provide context information to /// the node view without adding it to the document itself. /// /// `innerDecorations` holds the decorations for the node's content. /// You can safely ignore this if your view has no content or a /// `contentDOM` property, since the editor will draw the decorations /// on the content. But if you, for example, want to create a nested /// editor with the content, it may make sense to provide it with the /// inner decorations. /// /// (For backwards compatibility reasons, [mark /// views](#view.EditorProps.markViews) can also be included in this /// object.) nodeViews?: {[node: string]: NodeViewConstructor} /// Pass custom mark rendering functions. Note that these cannot /// provide the kind of dynamic behavior that [node /// views](#view.NodeView) can—they just provide custom rendering /// logic. The third argument indicates whether the mark's content /// is inline. markViews?: {[mark: string]: MarkViewConstructor} /// The DOM serializer to use when putting content onto the /// clipboard. If not given, the result of /// [`DOMSerializer.fromSchema`](#model.DOMSerializer^fromSchema) /// will be used. This object will only have its /// [`serializeFragment`](#model.DOMSerializer.serializeFragment) /// method called, and you may provide an alternative object type /// implementing a compatible method. clipboardSerializer?: DOMSerializer /// A function that will be called to get the text for the current /// selection when copying text to the clipboard. By default, the /// editor will use [`textBetween`](#model.Node.textBetween) on the /// selected range. clipboardTextSerializer?: (this: P, content: Slice, view: EditorView) => string /// A set of [document decorations](#view.Decoration) to show in the /// view. decorations?: (this: P, state: EditorState) => DecorationSource | null | undefined /// When this returns false, the content of the view is not directly /// editable. editable?: (this: P, state: EditorState) => boolean /// Control the DOM attributes of the editable element. May be either /// an object or a function going from an editor state to an object. /// By default, the element will get a class `"ProseMirror"`, and /// will have its `contentEditable` attribute determined by the /// [`editable` prop](#view.EditorProps.editable). Additional classes /// provided here will be added to the class. For other attributes, /// the value provided first (as in /// [`someProp`](#view.EditorView.someProp)) will be used. attributes?: {[name: string]: string} | ((state: EditorState) => {[name: string]: string}) /// Determines the distance (in pixels) between the cursor and the /// end of the visible viewport at which point, when scrolling the /// cursor into view, scrolling takes place. Defaults to 0. scrollThreshold?: number | {top: number, right: number, bottom: number, left: number} /// Determines the extra space (in pixels) that is left above or /// below the cursor when it is scrolled into view. Defaults to 5. scrollMargin?: number | {top: number, right: number, bottom: number, left: number} } /// The props object given directly to the editor view supports some /// fields that can't be used in plugins: export interface DirectEditorProps extends EditorProps { /// The current state of the editor. state: EditorState /// A set of plugins to use in the view, applying their [plugin /// view](#state.PluginSpec.view) and /// [props](#state.PluginSpec.props). Passing plugins with a state /// component (a [state field](#state.PluginSpec.state) field or a /// [transaction](#state.PluginSpec.filterTransaction) filter or /// appender) will result in an error, since such plugins must be /// present in the state to work. plugins?: readonly Plugin[] /// The callback over which to send transactions (state updates) /// produced by the view. If you specify this, you probably want to /// make sure this ends up calling the view's /// [`updateState`](#view.EditorView.updateState) method with a new /// state that has the transaction /// [applied](#state.EditorState.apply). The callback will be bound to have /// the view instance as its `this` binding. dispatchTransaction?: (tr: Transaction) => void } prosemirror-view/src/input.ts000066400000000000000000001043711527132543100167200ustar00rootroot00000000000000import {Selection, NodeSelection, TextSelection} from "prosemirror-state" import {dropPoint} from "prosemirror-transform" import {Slice, Node} from "prosemirror-model" import * as browser from "./browser" import {captureKeyDown} from "./capturekeys" import {parseFromClipboard, serializeForClipboard} from "./clipboard" import {selectionBetween, selectionToDOM, selectionFromDOM} from "./selection" import {keyEvent, DOMNode, textNodeBefore, textNodeAfter, isElt, isText} from "./dom" import {EditorView} from "./index" import {ViewDesc} from "./viewdesc" // A collection of DOM events that occur within the editor, and callback functions // to invoke when the event fires. const handlers: {[event: string]: (view: EditorView, event: Event) => void} = {} const editHandlers: {[event: string]: (view: EditorView, event: Event) => void} = {} const passiveHandlers: Record = {touchstart: true, touchmove: true} export class InputState { shiftKey = false mouseDown: MouseDown | null = null lastKeyCode: number | null = null lastKeyCodeTime = 0 lastKeyDispatched = false lastClick = {time: 0, x: 0, y: 0, type: "", button: 0} lastSelectionOrigin: string | null = null lastSelectionTime = 0 lastIOSEnter = 0 lastIOSEnterFallbackTimeout = -1 lastFocus = 0 lastTouch = 0 lastChromeDelete = 0 composing = false compositionNode: Text | null = null composingTimeout = -1 compositionNodes: ViewDesc[] = [] compositionEndedAt = -2e8 compositionID = 1 badSafariComposition = false // Set to a composition ID when there are pending changes at compositionend compositionPendingChanges = 0 domChangeCount = 0 eventHandlers: {[event: string]: (event: Event) => void} = Object.create(null) hideSelectionGuard: (() => void) | null = null } export function initInput(view: EditorView) { for (let event in handlers) { let handler = handlers[event] view.dom.addEventListener(event, view.input.eventHandlers[event] = (event: Event) => { if (eventBelongsToView(view, event) && !runCustomHandler(view, event) && (view.editable || !(event.type in editHandlers))) handler(view, event) }, passiveHandlers[event] ? {passive: true} : undefined) } // On Safari, for reasons beyond my understanding, adding an input // event handler makes an issue where the composition vanishes when // you press enter go away. if (browser.safari) view.dom.addEventListener("input", () => null) ensureListeners(view) } function setSelectionOrigin(view: EditorView, origin: string) { view.input.lastSelectionOrigin = origin view.input.lastSelectionTime = Date.now() } export function destroyInput(view: EditorView) { if (view.input.mouseDown) view.input.mouseDown.done() view.domObserver.stop() for (let type in view.input.eventHandlers) view.dom.removeEventListener(type, view.input.eventHandlers[type]) clearTimeout(view.input.composingTimeout) clearTimeout(view.input.lastIOSEnterFallbackTimeout) } export function ensureListeners(view: EditorView) { view.someProp("handleDOMEvents", currentHandlers => { for (let type in currentHandlers) if (!view.input.eventHandlers[type]) view.dom.addEventListener(type, view.input.eventHandlers[type] = event => runCustomHandler(view, event)) }) } function runCustomHandler(view: EditorView, event: Event) { return view.someProp("handleDOMEvents", handlers => { let handler = handlers[event.type] return handler ? handler(view, event) || event.defaultPrevented : false }) } function eventBelongsToView(view: EditorView, event: Event) { if (!event.bubbles) return true if (event.defaultPrevented) return false for (let node = event.target as DOMNode; node != view.dom; node = node.parentNode!) if (!node || node.nodeType == 11 || (node.pmViewDesc && node.pmViewDesc.stopEvent(event))) return false return true } export function dispatchEvent(view: EditorView, event: Event) { if (!runCustomHandler(view, event) && handlers[event.type] && (view.editable || !(event.type in editHandlers))) handlers[event.type](view, event) } export function dispatchKey(view: EditorView, event: KeyboardEvent | "Backspace" | "Enter") { let {input} = view if (input.lastKeyDispatched || view.composing) return false input.lastKeyDispatched = true if (typeof event == "string") event = keyEvent(event == "Backspace" ? 8 : 13, event) return view.someProp("handleKeyDown", f => f(view, event)) } editHandlers.keydown = (view: EditorView, _event: Event) => { let event = _event as KeyboardEvent view.input.shiftKey = event.keyCode == 16 || event.shiftKey if (inOrNearComposition(view, event)) return view.input.lastKeyCode = event.keyCode view.input.lastKeyCodeTime = Date.now() view.input.lastKeyDispatched = false // Suppress enter key events on Chrome Android, because those tend // to be part of a confused sequence of composition events fired, // and handling them eagerly tends to corrupt the input. if (browser.android && browser.chrome && event.keyCode == 13) return if (event.keyCode != 229) view.domObserver.forceFlush() // On iOS, if we preventDefault enter key presses, the virtual // keyboard gets confused. So the hack here is to set a flag that // makes the DOM change code recognize that what just happens should // be replaced by whatever the Enter key handlers do. if (browser.ios && event.keyCode == 13 && !event.ctrlKey && !event.altKey && !event.metaKey) { let now = Date.now() view.input.lastIOSEnter = now view.input.lastIOSEnterFallbackTimeout = setTimeout(() => { if (view.input.lastIOSEnter == now) { dispatchKey(view, "Enter") view.input.lastIOSEnter = 0 } }, 200) } else if (dispatchKey(view, event) || captureKeyDown(view, event)) { event.preventDefault() } else { setSelectionOrigin(view, "key") } } editHandlers.keyup = (view, event) => { if ((event as KeyboardEvent).keyCode == 16) view.input.shiftKey = false } editHandlers.keypress = (view, _event) => { let event = _event as KeyboardEvent if (inOrNearComposition(view, event) || !event.charCode || event.ctrlKey && !event.altKey || browser.mac && event.metaKey) return if (view.someProp("handleKeyPress", f => f(view, event))) { event.preventDefault() return } let sel = view.state.selection if (!(sel instanceof TextSelection) || !sel.$from.sameParent(sel.$to)) { let text = String.fromCharCode(event.charCode) let deflt = () => view.state.tr.insertText(text).scrollIntoView() if (!/[\r\n]/.test(text) && !view.someProp("handleTextInput", f => f(view, sel.$from.pos, sel.$to.pos, text, deflt))) view.dispatch(deflt()) event.preventDefault() } } function eventCoords(event: MouseEvent) { return {left: event.clientX, top: event.clientY} } function isNear(event: MouseEvent, click: {x: number, y: number}) { let dx = click.x - event.clientX, dy = click.y - event.clientY return dx * dx + dy * dy < 100 } function runHandlerOnContext( view: EditorView, propName: "handleClickOn" | "handleDoubleClickOn" | "handleTripleClickOn", pos: number, inside: number, event: MouseEvent ) { if (inside == -1) return false let $pos = view.state.doc.resolve(inside) for (let i = $pos.depth + 1; i > 0; i--) { if (view.someProp(propName, f => i > $pos.depth ? f(view, pos, $pos.nodeAfter!, $pos.before(i), event, true) : f(view, pos, $pos.node(i), $pos.before(i), event, false))) return true } return false } function updateSelection(view: EditorView, selection: Selection, origin: string) { if (!view.focused) view.focus() if (view.state.selection.eq(selection)) return let tr = view.state.tr.setSelection(selection) if (origin == "pointer") tr.setMeta("pointer", true) view.dispatch(tr) } function selectClickedLeaf(view: EditorView, inside: number) { if (inside == -1) return false let $pos = view.state.doc.resolve(inside), node = $pos.nodeAfter if (node && node.isAtom && NodeSelection.isSelectable(node)) { updateSelection(view, new NodeSelection($pos), "pointer") return true } return false } function selectClickedNode(view: EditorView, inside: number) { if (inside == -1) return false let sel = view.state.selection, selectedNode, selectAt if (sel instanceof NodeSelection) selectedNode = sel.node let $pos = view.state.doc.resolve(inside) for (let i = $pos.depth + 1; i > 0; i--) { let node = i > $pos.depth ? $pos.nodeAfter! : $pos.node(i) if (NodeSelection.isSelectable(node)) { if (selectedNode && sel.$from.depth > 0 && i >= sel.$from.depth && $pos.before(sel.$from.depth + 1) == sel.$from.pos) selectAt = $pos.before(sel.$from.depth) else selectAt = $pos.before(i) break } } if (selectAt != null) { updateSelection(view, NodeSelection.create(view.state.doc, selectAt), "pointer") return true } else { return false } } function handleSingleClick(view: EditorView, pos: number, inside: number, event: MouseEvent, selectNode: boolean) { return runHandlerOnContext(view, "handleClickOn", pos, inside, event) || view.someProp("handleClick", f => f(view, pos, event)) || (selectNode ? selectClickedNode(view, inside) : selectClickedLeaf(view, inside)) } function handleDoubleClick(view: EditorView, pos: number, inside: number, event: MouseEvent) { return runHandlerOnContext(view, "handleDoubleClickOn", pos, inside, event) || view.someProp("handleDoubleClick", f => f(view, pos, event)) } function handleTripleClick(view: EditorView, pos: number, inside: number, event: MouseEvent) { return runHandlerOnContext(view, "handleTripleClickOn", pos, inside, event) || view.someProp("handleTripleClick", f => f(view, pos, event)) || defaultTripleClick(view, inside, event) } function defaultTripleClick(view: EditorView, inside: number, event: MouseEvent) { if (event.button != 0) return false let selection = selectionForTripleClick(view, inside, true), doc = view.state.doc if (!selection) return false updateSelection(view, selection, "pointer") if (selection instanceof TextSelection && doc.eq(view.state.doc)) view.input.mouseDown = new TripleClickDrag(view, selection) return true } function selectionForTripleClick(view: EditorView, inside: number, selectNodes: boolean): Selection | null { let doc = view.state.doc if (inside == -1) return doc.inlineContent ? TextSelection.create(doc, 0, doc.content.size) : null let $pos = doc.resolve(inside) for (let i = $pos.depth + 1; i > 0; i--) { let node = i > $pos.depth ? $pos.nodeAfter! : $pos.node(i) let nodePos = $pos.before(i) if (node.inlineContent) return TextSelection.create(doc, nodePos + 1, nodePos + 1 + node.content.size) else if (selectNodes && NodeSelection.isSelectable(node)) return NodeSelection.create(doc, nodePos) } return null } function forceDOMFlush(view: EditorView) { return endComposition(view) } const selectNodeModifier: keyof MouseEvent = browser.mac ? "metaKey" : "ctrlKey" handlers.mousedown = (view, _event) => { let event = _event as MouseEvent view.input.shiftKey = event.shiftKey let flushed = forceDOMFlush(view) let now = Date.now(), type = "singleClick" if (now - view.input.lastClick.time < 500 && isNear(event, view.input.lastClick) && !event[selectNodeModifier] && view.input.lastClick.button == event.button) { if (view.input.lastClick.type == "singleClick") type = "doubleClick" else if (view.input.lastClick.type == "doubleClick") type = "tripleClick" } view.input.lastClick = {time: now, x: event.clientX, y: event.clientY, type, button: event.button} if (view.input.mouseDown) view.input.mouseDown.done() let pos = view.posAtCoords(eventCoords(event)) if (!pos) return if (type == "singleClick") { view.input.mouseDown = new LeftMouseDown(view, pos, event, !!flushed) } else if ((type == "doubleClick" ? handleDoubleClick : handleTripleClick)(view, pos.pos, pos.inside, event)) { event.preventDefault() } else { setSelectionOrigin(view, "pointer") } } abstract class MouseDown { mightDrag: {node: Node, pos: number, addAttr: boolean, setUneditable: boolean} | null = null constructor(readonly view: EditorView) { view.root.addEventListener("mouseup", this.up = this.up.bind(this) as any) view.root.addEventListener("mousemove", this.move = this.move.bind(this) as any) } up(event: MouseEvent) { this.done() } move(event: MouseEvent) { if (event.buttons == 0) this.done() } done() { this.view.root.removeEventListener("mouseup", this.up as any) this.view.root.removeEventListener("mousemove", this.move as any) if (this.view.input.mouseDown == this) this.view.input.mouseDown = null } delaySelUpdate() { return false } } class LeftMouseDown extends MouseDown { startDoc: Node selectNode: boolean allowDefault: boolean delayedSelectionSync = false target: HTMLElement | null constructor( view: EditorView, readonly pos: {pos: number, inside: number}, readonly event: MouseEvent, readonly flushed: boolean ) { super(view) this.startDoc = view.state.doc this.selectNode = !!event[selectNodeModifier] this.allowDefault = event.shiftKey let targetNode: Node, targetPos if (pos.inside > -1) { targetNode = view.state.doc.nodeAt(pos.inside)! targetPos = pos.inside } else { let $pos = view.state.doc.resolve(pos.pos) targetNode = $pos.parent targetPos = $pos.depth ? $pos.before() : 0 } const target = flushed ? null : event.target as HTMLElement const targetDesc = target ? view.docView.nearestDesc(target, true) : null this.target = targetDesc && isElt(targetDesc.nodeDOM) ? targetDesc.nodeDOM : null let {selection} = view.state if (event.button == 0 && (targetNode.type.spec.draggable && targetNode.type.spec.selectable !== false || selection instanceof NodeSelection && selection.from <= targetPos && selection.to > targetPos)) this.mightDrag = { node: targetNode, pos: targetPos, addAttr: !!(this.target && !this.target.draggable), setUneditable: !!(this.target && browser.gecko && !this.target.hasAttribute("contentEditable")) } if (this.target && this.mightDrag && (this.mightDrag.addAttr || this.mightDrag.setUneditable)) { this.view.domObserver.stop() if (this.mightDrag.addAttr) this.target.draggable = true if (this.mightDrag.setUneditable) setTimeout(() => { if (this.view.input.mouseDown == this) this.target!.setAttribute("contentEditable", "false") }, 20) this.view.domObserver.start() } setSelectionOrigin(view, "pointer") } done() { super.done() if (this.mightDrag && this.target) { this.view.domObserver.stop() if (this.mightDrag.addAttr) this.target.removeAttribute("draggable") if (this.mightDrag.setUneditable) this.target.removeAttribute("contentEditable") this.view.domObserver.start() } if (this.delayedSelectionSync) setTimeout(() => { if (!this.view.isDestroyed) selectionToDOM(this.view) }) } up(event: MouseEvent) { this.done() if (!this.view.dom.contains(event.target as HTMLElement)) return let pos: {pos: number, inside: number} | null = this.pos if (this.view.state.doc != this.startDoc) pos = this.view.posAtCoords(eventCoords(event)) this.updateAllowDefault(event) if (this.allowDefault || !pos) { setSelectionOrigin(this.view, "pointer") } else if (handleSingleClick(this.view, pos.pos, pos.inside, event, this.selectNode)) { event.preventDefault() } else if (event.button == 0 && (this.flushed || // Safari ignores clicks on draggable elements (browser.safari && this.mightDrag && !this.mightDrag.node.isAtom) || // Chrome will sometimes treat a node selection as a // cursor, but still report that the node is selected // when asked through getSelection. You'll then get a // situation where clicking at the point where that // (hidden) cursor is doesn't change the selection, and // thus doesn't get a reaction from ProseMirror. This // works around that. (browser.chrome && !this.view.state.selection.visible && Math.min(Math.abs(pos.pos - this.view.state.selection.from), Math.abs(pos.pos - this.view.state.selection.to)) <= 2))) { updateSelection(this.view, Selection.near(this.view.state.doc.resolve(pos.pos)), "pointer") event.preventDefault() } else { setSelectionOrigin(this.view, "pointer") } } move(event: MouseEvent) { this.updateAllowDefault(event) setSelectionOrigin(this.view, "pointer") super.move(event) } updateAllowDefault(event: MouseEvent) { if (!this.allowDefault && (Math.abs(this.event.x - event.clientX) > 4 || Math.abs(this.event.y - event.clientY) > 4)) this.allowDefault = true } delaySelUpdate() { if (!this.allowDefault) return false this.delayedSelectionSync = true return true } } class TripleClickDrag extends MouseDown { startDoc: Node constructor(view: EditorView, readonly startSelection: TextSelection) { super(view) this.startDoc = view.state.doc } move(event: MouseEvent) { if (event.buttons == 0 || this.view.isDestroyed || !this.view.state.doc.eq(this.startDoc)) { this.done() return } event.preventDefault() setSelectionOrigin(this.view, "pointer") let pos = this.view.posAtCoords(eventCoords(event)) let target = pos && selectionForTripleClick(this.view, pos.inside, false) if (!target) return let {doc} = this.view.state, start = this.startSelection let [anchor, head] = target.from < start.from ? [start.to, target.from] : [start.from, target.to] updateSelection(this.view, TextSelection.create(doc, anchor, head), "pointer") } } handlers.touchstart = view => { view.input.lastTouch = Date.now() forceDOMFlush(view) setSelectionOrigin(view, "pointer") } handlers.touchmove = view => { view.input.lastTouch = Date.now() setSelectionOrigin(view, "pointer") } handlers.contextmenu = view => forceDOMFlush(view) function inOrNearComposition(view: EditorView, event: Event) { if (view.composing) return true // See https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/. // On Japanese input method editors (IMEs), the Enter key is used to confirm character // selection. On Safari, when Enter is pressed, compositionend and keydown events are // emitted. The keydown event triggers newline insertion, which we don't want. // This method returns true if the keydown event should be ignored. // We only ignore it once, as pressing Enter a second time *should* insert a newline. // Furthermore, the keydown event timestamp must be close to the compositionEndedAt timestamp. // This guards against the case where compositionend is triggered without the keyboard // (e.g. character confirmation may be done with the mouse), and keydown is triggered // afterwards- we wouldn't want to ignore the keydown event in this case. if (browser.safari && Math.abs(Date.now() - view.input.compositionEndedAt) < 500) { view.input.compositionEndedAt = -2e8 return true } return false } // Drop active composition after 5 seconds of inactivity on Android const timeoutComposition = browser.android ? 5000 : -1 editHandlers.compositionstart = editHandlers.compositionupdate = view => { if (!view.composing) { view.domObserver.flush() let {state} = view, $pos = state.selection.$to if (state.selection instanceof TextSelection && state.selection.empty && (state.storedMarks || (!$pos.textOffset && $pos.parentOffset && $pos.nodeBefore!.marks.some(m => m.type.spec.inclusive === false)) || browser.chrome && browser.windows && selectionBeforeUneditable(view))) { // Issue #1500 // Need to wrap the cursor in mark nodes different from the ones in the DOM context view.markCursor = view.state.storedMarks || $pos.marks() endComposition(view, true) view.markCursor = null } else { endComposition(view, !state.selection.empty) // In firefox, if the cursor is after but outside a marked node, // the inserted text won't inherit the marks. So this moves it // inside if necessary. if (browser.gecko && state.selection.empty && $pos.parentOffset && !$pos.textOffset && $pos.nodeBefore!.marks.length) { let sel = view.domSelectionRange() for (let node = sel.focusNode, offset = sel.focusOffset; node && isElt(node) && offset != 0;) { let before = offset < 0 ? node.lastChild : node.childNodes[offset - 1] if (!before) break if (isText(before)) { let sel = view.domSelection() if (sel) sel.collapse(before, before.nodeValue!.length) break } else { node = before offset = -1 } } } } view.input.composing = true } scheduleComposeEnd(view, timeoutComposition) } function selectionBeforeUneditable(view: EditorView) { let {focusNode, focusOffset} = view.domSelectionRange() if (!focusNode || !isElt(focusNode) || focusOffset >= focusNode.childNodes.length) return false let next = focusNode.childNodes[focusOffset] return isElt(next) && next.contentEditable == "false" } editHandlers.compositionend = (view, event) => { if (view.composing) { view.input.composing = false view.input.compositionEndedAt = Date.now() view.input.compositionPendingChanges = view.domObserver.pendingRecords().length ? view.input.compositionID : 0 view.input.compositionNode = null if (view.input.badSafariComposition) view.domObserver.forceFlush() else if (view.input.compositionPendingChanges) Promise.resolve().then(() => view.domObserver.flush()) view.input.compositionID++ scheduleComposeEnd(view, 20) } } function scheduleComposeEnd(view: EditorView, delay: number) { clearTimeout(view.input.composingTimeout) if (delay > -1) view.input.composingTimeout = setTimeout(() => endComposition(view), delay) } export function clearComposition(view: EditorView) { if (view.composing) { view.input.composing = false view.input.compositionEndedAt = Date.now() } while (view.input.compositionNodes.length > 0) view.input.compositionNodes.pop()!.markParentsDirty() } export function findCompositionNode(view: EditorView) { let sel = view.domSelectionRange() if (!sel.focusNode) return null let textBefore = textNodeBefore(sel.focusNode, sel.focusOffset) let textAfter = textNodeAfter(sel.focusNode, sel.focusOffset) if (textBefore && textAfter && textBefore != textAfter) { let descAfter = textAfter.pmViewDesc, lastChanged = view.domObserver.lastChangedTextNode if (textBefore == lastChanged || textAfter == lastChanged) return lastChanged if (!descAfter || !descAfter.isText(textAfter.nodeValue!)) { return textAfter } else if (view.input.compositionNode == textAfter) { let descBefore = textBefore.pmViewDesc if (!(!descBefore || !descBefore.isText(textBefore.nodeValue!))) return textAfter } } return textBefore || textAfter } /// @internal export function endComposition(view: EditorView, restarting = false) { if (browser.android && view.domObserver.flushingSoon >= 0) return view.domObserver.forceFlush() clearComposition(view) if (restarting || view.docView && view.docView.dirty) { let sel = selectionFromDOM(view), cur = view.state.selection if (sel && !sel.eq(cur)) view.dispatch(view.state.tr.setSelection(sel)) else if ((view.markCursor || restarting) && !cur.$from.node(cur.$from.sharedDepth(cur.to)).inlineContent) view.dispatch(view.state.tr.deleteSelection()) else view.updateState(view.state) return true } return false } function captureCopy(view: EditorView, dom: HTMLElement) { // The extra wrapper is somehow necessary on IE/Edge to prevent the // content from being mangled when it is put onto the clipboard if (!view.dom.parentNode) return let wrap = view.dom.parentNode.appendChild(document.createElement("div")) wrap.appendChild(dom) wrap.style.cssText = "position: fixed; left: -10000px; top: 10px" let sel = getSelection()!, range = document.createRange() range.selectNodeContents(dom) // Done because IE will fire a selectionchange moving the selection // to its start when removeAllRanges is called and the editor still // has focus (which will mess up the editor's selection state). view.dom.blur() sel.removeAllRanges() sel.addRange(range) setTimeout(() => { if (wrap.parentNode) wrap.parentNode.removeChild(wrap) view.focus() }, 50) } // 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) handlers.copy = editHandlers.cut = (view, _event) => { let event = _event as ClipboardEvent let sel = view.state.selection, cut = event.type == "cut" if (sel.empty) return // IE and Edge's clipboard interface is completely broken let data = brokenClipboardAPI ? null : event.clipboardData let slice = sel.content(), {dom, text} = serializeForClipboard(view, slice) if (data) { event.preventDefault() data.clearData() data.setData("text/html", dom.innerHTML) data.setData("text/plain", text) } else { captureCopy(view, dom) } if (cut) view.dispatch(view.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent", "cut")) } function sliceSingleNode(slice: Slice) { return slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1 ? slice.content.firstChild : null } function capturePaste(view: EditorView, event: ClipboardEvent) { if (!view.dom.parentNode) return let plainText = view.input.shiftKey || view.state.selection.$from.parent.type.spec.code let target = view.dom.parentNode.appendChild(document.createElement(plainText ? "textarea" : "div")) if (!plainText) target.contentEditable = "true" target.style.cssText = "position: fixed; left: -10000px; top: 10px" target.focus() let plain = view.input.shiftKey && view.input.lastKeyCode != 45 setTimeout(() => { view.focus() if (target.parentNode) target.parentNode.removeChild(target) if (plainText) doPaste(view, (target as HTMLTextAreaElement).value, null, plain, event) else doPaste(view, target.textContent!, target.innerHTML, plain, event) }, 50) } export function doPaste(view: EditorView, text: string, html: string | null, preferPlain: boolean, event: ClipboardEvent) { let slice = parseFromClipboard(view, text, html, preferPlain, view.state.selection.$from) if (view.someProp("handlePaste", f => f(view, event, slice || Slice.empty))) return true if (!slice) return false let singleNode = sliceSingleNode(slice) let tr = singleNode ? view.state.tr.replaceSelectionWith(singleNode, preferPlain) : view.state.tr.replaceSelection(slice) view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste")) return true } function getText(clipboardData: DataTransfer) { let text = clipboardData.getData("text/plain") || clipboardData.getData("Text") if (text) return text let uris = clipboardData.getData("text/uri-list") return uris ? uris.replace(/\r?\n/g, " ") : "" } editHandlers.paste = (view, _event) => { let event = _event as ClipboardEvent // Handling paste from JavaScript during composition is very poorly // handled by browsers, so as a dodgy but preferable kludge, we just // let the browser do its native thing there, except on Android, // where the editor is almost always composing. if (view.composing && !browser.android) return let data = brokenClipboardAPI ? null : event.clipboardData let plain = view.input.shiftKey && view.input.lastKeyCode != 45 if (data && doPaste(view, getText(data), data.getData("text/html"), plain, event)) event.preventDefault() else capturePaste(view, event) } export class Dragging { constructor(readonly slice: Slice, readonly move: boolean, readonly node?: NodeSelection) {} } const dragCopyModifier: keyof DragEvent = browser.mac ? "altKey" : "ctrlKey" function dragMoves(view: EditorView, event: DragEvent) { let copy: boolean | undefined view.someProp("dragCopies", test => { copy = copy || test(event) }) return copy != null ? !copy : !event[dragCopyModifier] } handlers.dragstart = (view, _event) => { let event = _event as DragEvent let mouseDown = view.input.mouseDown if (mouseDown) mouseDown.done() if (!event.dataTransfer) return let sel = view.state.selection let pos = sel.empty ? null : view.posAtCoords(eventCoords(event)) let node: undefined | NodeSelection if (pos && pos.pos >= sel.from && pos.pos <= (sel instanceof NodeSelection ? sel.to - 1: sel.to)) { // In selection } else if (mouseDown && mouseDown.mightDrag) { node = NodeSelection.create(view.state.doc, mouseDown.mightDrag.pos) } else if (event.target && isElt(event.target as HTMLElement)) { let desc = view.docView.nearestDesc(event.target as HTMLElement, true) if (desc && desc.node.type.spec.draggable && desc != view.docView) node = NodeSelection.create(view.state.doc, desc.posBefore) } let draggedSlice = (node || view.state.selection).content() let {dom, text, slice} = serializeForClipboard(view, draggedSlice) // Pre-120 Chrome versions clear files when calling `clearData` (#1472) if (!event.dataTransfer.files.length || !browser.chrome || browser.chrome_version > 120) event.dataTransfer.clearData() event.dataTransfer.setData(brokenClipboardAPI ? "Text" : "text/html", dom.innerHTML) // See https://code.haverbeke.berlin/prosemirror/prosemirror/issues/1156 event.dataTransfer.effectAllowed = "copyMove" if (!brokenClipboardAPI) event.dataTransfer.setData("text/plain", text) view.dragging = new Dragging(slice, dragMoves(view, event), node) } handlers.dragend = view => { let dragging = view.dragging window.setTimeout(() => { if (view.dragging == dragging) view.dragging = null }, 50) } editHandlers.dragover = editHandlers.dragenter = (_, e) => e.preventDefault() editHandlers.drop = (view, event) => { try { handleDrop(view, event as DragEvent, view.dragging) } finally { view.dragging = null } } function handleDrop(view: EditorView, event: DragEvent, dragging: Dragging | null) { if (!event.dataTransfer) return let eventPos = view.posAtCoords(eventCoords(event)) if (!eventPos) return let $mouse = view.state.doc.resolve(eventPos.pos) let slice = dragging && dragging.slice if (slice) { view.someProp("transformPasted", f => { slice = f(slice!, view, false) }) } else { slice = parseFromClipboard(view, getText(event.dataTransfer), brokenClipboardAPI ? null : event.dataTransfer.getData("text/html"), false, $mouse) } let move = !!(dragging && dragMoves(view, event)) if (view.someProp("handleDrop", f => f(view, event, slice || Slice.empty, move))) { event.preventDefault() return } if (!slice) return event.preventDefault() let insertPos = slice ? dropPoint(view.state.doc, $mouse.pos, slice) : $mouse.pos if (insertPos == null) insertPos = $mouse.pos let tr = view.state.tr if (move) { let {node} = dragging as Dragging if (node) node.replace(tr) else tr.deleteSelection() } let pos = tr.mapping.map(insertPos) let isNode = slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1 let beforeInsert = tr.doc if (isNode) tr.replaceRangeWith(pos, pos, slice.content.firstChild!) else tr.replaceRange(pos, pos, slice) if (tr.doc.eq(beforeInsert)) return let $pos = tr.doc.resolve(pos) if (isNode && NodeSelection.isSelectable(slice.content.firstChild!) && $pos.nodeAfter && $pos.nodeAfter.sameMarkup(slice.content.firstChild!)) { tr.setSelection(new NodeSelection($pos)) } else { let end = tr.mapping.map(insertPos) tr.mapping.maps[tr.mapping.maps.length - 1].forEach((_from, _to, _newFrom, newTo) => end = newTo) tr.setSelection(selectionBetween(view, $pos, tr.doc.resolve(end))) } view.focus() view.dispatch(tr.setMeta("uiEvent", "drop")) } handlers.focus = view => { view.input.lastFocus = Date.now() if (!view.focused) { view.domObserver.stop() view.dom.classList.add("ProseMirror-focused") view.domObserver.start() view.focused = true let startSel = view.domSelectionRange() if (!view.domObserver.currentSelection.eq(startSel)) { let {focusNode, focusOffset} = startSel setTimeout(() => { let newSel = view.domSelectionRange() if (newSel.focusNode == focusNode && newSel.focusOffset == focusOffset && view.docView && view.hasFocus()) selectionToDOM(view) }, 20) } } } handlers.blur = (view, _event) => { let event = _event as FocusEvent if (view.focused) { view.domObserver.stop() view.dom.classList.remove("ProseMirror-focused") view.domObserver.start() if (event.relatedTarget && view.dom.contains(event.relatedTarget as HTMLElement)) view.domObserver.currentSelection.clear() view.focused = false } } handlers.beforeinput = (view, _event: Event) => { let event = _event as InputEvent // We should probably do more with beforeinput events, but support // is so spotty that I'm still waiting to see where they are going. // Very specific hack to deal with backspace sometimes failing on // Chrome and Firefox Android when after an uneditable node. if (browser.android && event.inputType == "deleteContentBackward") { view.domObserver.flushSoon() let {domChangeCount} = view.input setTimeout(() => { if (view.input.domChangeCount != domChangeCount || view.domObserver.pendingRecords().length) return // Event already had some effect // This bug tends to close the virtual keyboard, so we refocus view.dom.blur() view.focus() if (dispatchKey(view, "Backspace")) return let {$cursor} = view.state.selection as TextSelection // Crude approximation of backspace behavior when no command handled it if ($cursor && $cursor.pos > 0) view.dispatch(view.state.tr.delete($cursor.pos - 1, $cursor.pos).scrollIntoView()) }, 50) } } // Make sure all handlers get registered for (let prop in editHandlers) handlers[prop] = editHandlers[prop] prosemirror-view/src/selection.ts000066400000000000000000000225241527132543100175450ustar00rootroot00000000000000import {TextSelection, NodeSelection, Selection} from "prosemirror-state" import {ResolvedPos} from "prosemirror-model" import * as browser from "./browser" import {isEquivalentPosition, domIndex, isOnEdge, selectionCollapsed, isElt, contains} from "./dom" import {EditorView} from "./index" import {NodeViewDesc} from "./viewdesc" export function selectionFromDOM(view: EditorView, origin: string | null = null) { let domSel = view.domSelectionRange(), doc = view.state.doc if (!domSel.focusNode) return null let nearestDesc = view.docView.nearestDesc(domSel.focusNode), inWidget = nearestDesc && nearestDesc.size == 0 let head = view.docView.posFromDOM(domSel.focusNode, domSel.focusOffset, 1) if (head < 0) return null let $head = doc.resolve(head), anchor, selection if (selectionCollapsed(domSel)) { anchor = head while (nearestDesc && !nearestDesc.node) nearestDesc = nearestDesc.parent if (nearestDesc && nearestDesc.node!.isAtom && NodeSelection.isSelectable(nearestDesc.node!) && nearestDesc.parent && !(nearestDesc.node!.isInline && isOnEdge(domSel.focusNode, domSel.focusOffset, nearestDesc.dom))) { let pos = nearestDesc.posBefore selection = new NodeSelection(head == pos ? $head : doc.resolve(pos)) } else if (browser.gecko && origin == "key" && head > view.state.selection.to && isElt(domSel.focusNode) && domSel.focusOffset == domSel.focusNode.childNodes.length && nearestDesc && nearestDesc.posAtStart > view.state.selection.to) { // Firefox will jump across scrollable wrapper nodes on keyboard // cursor motion (#1594). This tries to detect this situation // and work around it. for (let scan = domSel.focusNode;; scan = scan.parentNode) { if (getComputedStyle(scan).overflow != "visible") { selection = Selection.findFrom(doc.resolve(nearestDesc.posAtStart), 1) break } if (scan == nearestDesc.dom || scan.nextSibling || !scan.parentNode || !isElt(scan.parentNode)) break } } } else { if (domSel instanceof view.dom.ownerDocument.defaultView!.Selection && domSel.rangeCount > 1) { let min = head, max = head for (let i = 0; i < domSel.rangeCount; i++) { let range = domSel.getRangeAt(i) min = Math.min(min, view.docView.posFromDOM(range.startContainer, range.startOffset, 1)) max = Math.max(max, view.docView.posFromDOM(range.endContainer, range.endOffset, -1)) } if (min < 0) return null ;[anchor, head] = max == view.state.selection.anchor ? [max, min] : [min, max] $head = doc.resolve(head) } else { anchor = view.docView.posFromDOM(domSel.anchorNode!, domSel.anchorOffset, 1) } if (anchor < 0) return null } let $anchor = doc.resolve(anchor) if (!selection) { let bias = origin == "pointer" || (view.state.selection.head < $head.pos && !inWidget) ? 1 : -1 selection = selectionBetween(view, $anchor, $head, bias) } return selection } function editorOwnsSelection(view: EditorView) { return view.editable ? view.hasFocus() : hasSelection(view) && document.activeElement && document.activeElement.contains(view.dom) } export function selectionToDOM(view: EditorView, force = false) { let sel = view.state.selection syncNodeSelection(view, sel) if (!editorOwnsSelection(view)) return // Need to delay selection normalization during a native selection // drag on Chrome, or it will cause further dragging to glitch. let mouseDown = view.input.mouseDown if (!force && browser.chrome && mouseDown) { let domSel = view.domSelectionRange(), curSel = view.domObserver.currentSelection if (domSel.anchorNode && curSel.anchorNode && isEquivalentPosition(domSel.anchorNode, domSel.anchorOffset, curSel.anchorNode, curSel.anchorOffset) && mouseDown.delaySelUpdate()) { view.domObserver.setCurSelection() return } } view.domObserver.disconnectSelection() if (view.cursorWrapper) { selectCursorWrapper(view) } else { let {anchor, head} = sel, resetEditableFrom, resetEditableTo if (brokenSelectBetweenUneditable && !(sel instanceof TextSelection)) { if (!sel.$from.parent.inlineContent) resetEditableFrom = temporarilyEditableNear(view, sel.from) if (!sel.empty && !sel.$from.parent.inlineContent) resetEditableTo = temporarilyEditableNear(view, sel.to) } view.docView.setSelection(anchor, head, view, force) if (brokenSelectBetweenUneditable) { if (resetEditableFrom) resetEditable(resetEditableFrom) if (resetEditableTo) resetEditable(resetEditableTo) } if (sel.visible) { view.dom.classList.remove("ProseMirror-hideselection") } else { view.dom.classList.add("ProseMirror-hideselection") if ("onselectionchange" in document) removeClassOnSelectionChange(view) } } view.domObserver.setCurSelection() view.domObserver.connectSelection() } // Kludge to work around Webkit not allowing a selection to start/end // between non-editable block nodes. We briefly make something // editable, set the selection, then set it uneditable again. const brokenSelectBetweenUneditable = browser.safari || browser.chrome && browser.chrome_version < 63 function temporarilyEditableNear(view: EditorView, pos: number) { let {node, offset} = view.docView.domFromPos(pos, 0) let after = offset < node.childNodes.length ? node.childNodes[offset] : null let before = offset ? node.childNodes[offset - 1] : null if (browser.safari && after && (after as HTMLElement).contentEditable == "false") return setEditable(after as HTMLElement) if ((!after || (after as HTMLElement).contentEditable == "false") && (!before || (before as HTMLElement).contentEditable == "false")) { if (after) return setEditable(after as HTMLElement) else if (before) return setEditable(before as HTMLElement) } } function setEditable(element: HTMLElement) { element.contentEditable = "true" if (browser.safari && element.draggable) { element.draggable = false; (element as any).wasDraggable = true } return element } function resetEditable(element: HTMLElement) { element.contentEditable = "false" if ((element as any).wasDraggable) { element.draggable = true; (element as any).wasDraggable = null } } function removeClassOnSelectionChange(view: EditorView) { let doc = view.dom.ownerDocument doc.removeEventListener("selectionchange", view.input.hideSelectionGuard!) let domSel = view.domSelectionRange() let node = domSel.anchorNode, offset = domSel.anchorOffset doc.addEventListener("selectionchange", view.input.hideSelectionGuard = () => { if (domSel.anchorNode != node || domSel.anchorOffset != offset) { doc.removeEventListener("selectionchange", view.input.hideSelectionGuard!) setTimeout(() => { if (!editorOwnsSelection(view) || view.state.selection.visible) view.dom.classList.remove("ProseMirror-hideselection") }, 20) } }) } function selectCursorWrapper(view: EditorView) { let domSel = view.domSelection() if (!domSel) return let node = view.cursorWrapper!.dom, img = node.nodeName == "IMG" if (img) domSel.collapse(node.parentNode!, domIndex(node) + 1) else domSel.collapse(node, 0) // Kludge to kill 'control selection' in IE11 when selecting an // invisible cursor wrapper, since that would result in those weird // resize handles and a selection that considers the absolutely // positioned wrapper, rather than the root editable node, the // focused element. if (!img && !view.state.selection.visible && browser.ie && browser.ie_version <= 11) { ;(node as any).disabled = true ;(node as any).disabled = false } } export function syncNodeSelection(view: EditorView, sel: Selection) { if (sel instanceof NodeSelection) { let desc = view.docView.descAt(sel.from) if (desc != view.lastSelectedViewDesc) { clearNodeSelection(view) if (desc) (desc as NodeViewDesc).selectNode() view.lastSelectedViewDesc = desc } } else { clearNodeSelection(view) } } // Clear all DOM statefulness of the last node selection. function clearNodeSelection(view: EditorView) { if (view.lastSelectedViewDesc) { if (view.lastSelectedViewDesc.parent) (view.lastSelectedViewDesc as NodeViewDesc).deselectNode() view.lastSelectedViewDesc = undefined } } export function selectionBetween(view: EditorView, $anchor: ResolvedPos, $head: ResolvedPos, bias?: number) { return view.someProp("createSelectionBetween", f => f(view, $anchor, $head)) || TextSelection.between($anchor, $head, bias) } export function hasFocusAndSelection(view: EditorView) { if (view.editable && !view.hasFocus()) return false return hasSelection(view) } export function hasSelection(view: EditorView) { let sel = view.domSelectionRange() if (!sel.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(view.dom, sel.anchorNode) && (view.editable || contains(view.dom, sel.focusNode)) } catch(_) { return false } } export function anchorInRightPlace(view: EditorView) { let anchorDOM = view.docView.domFromPos(view.state.selection.anchor, 0) let domSel = view.domSelectionRange() return isEquivalentPosition(anchorDOM.node, anchorDOM.offset, domSel.anchorNode!, domSel.anchorOffset) } prosemirror-view/src/viewdesc.ts000066400000000000000000001774441527132543100174050ustar00rootroot00000000000000import {DOMSerializer, Fragment, Mark, Node, TagParseRule} from "prosemirror-model" import {TextSelection} from "prosemirror-state" import {domIndex, isEquivalentPosition, DOMNode, isElt, isText, contains} from "./dom" import * as browser from "./browser" import {Decoration, DecorationSource, WidgetConstructor, WidgetType, NodeType} from "./decoration" import {EditorView} from "./index" declare global { interface Node { /// @internal pmViewDesc?: ViewDesc } } /// A ViewMutationRecord represents a DOM /// [mutation](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) /// or a selection change happens within the view. When the change is /// a selection change, the record will have a `type` property of /// `"selection"` (which doesn't occur for native mutation records). export type ViewMutationRecord = MutationRecord | { type: "selection", target: DOMNode } /// By default, document nodes are rendered using the result of the /// [`toDOM`](#model.NodeSpec.toDOM) method of their spec, and managed /// entirely by the editor. For some use cases, such as embedded /// node-specific editing interfaces, you want more control over /// the behavior of a node's in-editor representation, and need to /// [define](#view.EditorProps.nodeViews) a custom node view. /// /// Objects returned as node views must conform to this interface. export interface NodeView { /// The outer DOM node that represents the document node. dom: HTMLElement /// The DOM node that should hold the node's content. Only meaningful /// if the node view also defines a `dom` property and if its node /// type is not a leaf node type. When this is present, ProseMirror /// will take care of rendering the node's children into it. When it /// is not present, the node view itself is responsible for rendering /// (or deciding not to render) its child nodes. contentDOM?: HTMLElement | null /// When given, this will be called when the view is updating /// itself. It will be given a node, an array of active decorations /// around the node (which are automatically drawn, and the node /// view may ignore if it isn't interested in them), and a /// [decoration source](#view.DecorationSource) that represents any /// decorations that apply to the content of the node (which again /// may be ignored). It should return true if it was able to update /// to that node, and false otherwise. If the node view has a /// `contentDOM` property (or no `dom` property), updating its child /// nodes will be handled by ProseMirror. update?: (node: Node, decorations: readonly Decoration[], innerDecorations: DecorationSource) => boolean /// By default, `update` will only be called when a node of the same /// node type appears in this view's position. When you set this to /// true, it will be called for any node, making it possible to have /// a node view that representsmultiple types of nodes. You will /// need to check the type of the nodes you get in `update` and /// return `false` for types you cannot handle. multiType?: boolean /// Can be used to override the way the node's selected status (as a /// node selection) is displayed. selectNode?: () => void /// When defining a `selectNode` method, you should also provide a /// `deselectNode` method to remove the effect again. deselectNode?: () => void /// This will be called to handle setting the selection inside the /// node. The `anchor` and `head` positions are relative to the start /// of the node. By default, a DOM selection will be created between /// the DOM positions corresponding to those positions, but if you /// override it you can do something else. setSelection?: (anchor: number, head: number, root: Document | ShadowRoot) => void /// Can be used to prevent the editor view from trying to handle some /// or all DOM events that bubble up from the node view. Events for /// which this returns true are not handled by the editor. stopEvent?: (event: Event) => boolean /// Called when a [mutation](#view.ViewMutationRecord) happens within the /// view. Return false if the editor should re-read the selection or re-parse /// the range around the mutation, true if it can safely be ignored. ignoreMutation?: (mutation: ViewMutationRecord) => boolean /// Called when the node view is removed from the editor or the whole /// editor is destroyed. destroy?: () => void } /// By default, document marks are rendered using the result of the /// [`toDOM`](#model.MarkSpec.toDOM) method of their spec, and managed entirely /// by the editor. For some use cases, you want more control over the behavior /// of a mark's in-editor representation, and need to /// [define](#view.EditorProps.markViews) a custom mark view. /// /// Objects returned as mark views must conform to this interface. export interface MarkView { /// The outer DOM node that represents the document node. dom: HTMLElement /// The DOM node that should hold the mark's content. When this is not /// present, the `dom` property is used as the content DOM. contentDOM?: HTMLElement | null /// When given, this is called when the view is updating itself. It /// will be given a mark (of the same type as the current one). When it /// returns true, the existing DOM is kept and reused (its content is /// still managed by ProseMirror); when it returns false, the mark view /// is rebuilt. update?: (mark: Mark) => boolean /// Called when a [mutation](#view.ViewMutationRecord) happens within the /// view. Return false if the editor should re-read the selection or re-parse /// the range around the mutation, true if it can safely be ignored. ignoreMutation?: (mutation: ViewMutationRecord) => boolean /// Called when the mark view is removed from the editor or the whole /// editor is destroyed. destroy?: () => void } // View descriptions are data structures that describe the DOM that is // used to represent the editor's content. They are used for: // // - Incremental redrawing when the document changes // // - Figuring out what part of the document a given DOM position // corresponds to // // - Wiring in custom implementations of the editing interface for a // given node // // They form a doubly-linked mutable tree, starting at `view.docView`. const NOT_DIRTY = 0, CHILD_DIRTY = 1, CONTENT_DIRTY = 2, NODE_DIRTY = 3 // Superclass for the various kinds of descriptions. Defines their // basic structure and shared methods. export class ViewDesc { dirty = NOT_DIRTY declare node: Node | null constructor( public parent: ViewDesc | undefined, public children: ViewDesc[], public dom: DOMNode, // This is the node that holds the child views. It may be null for // descs that don't have children. public contentDOM: HTMLElement | null ) { // An expando property on the DOM node provides a link back to its // description. dom.pmViewDesc = this } // Used to check whether a given description corresponds to a // widget/mark/node. matchesWidget(widget: Decoration) { return false } matchesMark(mark: Mark) { return false } matchesNode(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource) { return false } matchesHack(nodeName: string) { return false } // When parsing in-editor content (in domchange.js), we allow // descriptions to determine the parse rules that should be used to // parse them. parseRule(addedNodes?: readonly DOMNode[]): Omit | null { return null } // Used by the editor's event handler to ignore events that come // from certain descs. stopEvent(event: Event) { return false } // The size of the content represented by this desc. get size() { let size = 0 for (let i = 0; i < this.children.length; i++) size += this.children[i].size return size } // For block nodes, this represents the space taken up by their // start/end tokens. get border() { return 0 } destroy() { this.parent = undefined if (this.dom.pmViewDesc == this) this.dom.pmViewDesc = undefined for (let i = 0; i < this.children.length; i++) this.children[i].destroy() } posBeforeChild(child: ViewDesc): number { for (let i = 0, pos = this.posAtStart;; i++) { let cur = this.children[i] if (cur == child) return pos pos += cur.size } } get posBefore() { return this.parent!.posBeforeChild(this) } get posAtStart() { return this.parent ? this.parent.posBeforeChild(this) + this.border : 0 } get posAfter() { return this.posBefore + this.size } get posAtEnd() { return this.posAtStart + this.size - 2 * this.border } localPosFromDOM(dom: DOMNode, offset: number, bias: number): number { // If the DOM position is in the content, use the child desc after // it to figure out a position. if (this.contentDOM && contains(this.contentDOM, dom)) { if (bias < 0) { let domBefore, desc: ViewDesc | undefined if (dom == this.contentDOM) { domBefore = dom.childNodes[offset - 1] } else { while (dom.parentNode != this.contentDOM) dom = dom.parentNode! domBefore = dom.previousSibling } while (domBefore && !((desc = domBefore.pmViewDesc) && desc.parent == this)) domBefore = domBefore.previousSibling return domBefore ? this.posBeforeChild(desc!) + desc!.size : this.posAtStart } else { let domAfter, desc: ViewDesc | undefined if (dom == this.contentDOM) { domAfter = dom.childNodes[offset] } else { while (dom.parentNode != this.contentDOM) dom = dom.parentNode! domAfter = dom.nextSibling } while (domAfter && !((desc = domAfter.pmViewDesc) && desc.parent == this)) domAfter = domAfter.nextSibling return domAfter ? this.posBeforeChild(desc!) : this.posAtEnd } } // Otherwise, use various heuristics, falling back on the bias // parameter, to determine whether to return the position at the // start or at the end of this view desc. let atEnd if (dom == this.dom && this.contentDOM) { atEnd = offset > domIndex(this.contentDOM) } else if (this.contentDOM && this.contentDOM != this.dom && this.dom.contains(this.contentDOM)) { let rel = dom.compareDocumentPosition(this.contentDOM) if (rel & 16) { // this.contentDOM is inside dom atEnd = offset > Array.from(dom.childNodes).findIndex(n => n.contains(this.contentDOM)) } else { // Check if after or before atEnd = !(rel & 4) } } else if (this.dom.firstChild) { if (offset == 0) for (let search = dom;; search = search.parentNode!) { if (search == this.dom) { atEnd = false; break } if (search.previousSibling) break } if (atEnd == null && offset == dom.childNodes.length) for (let search = dom;; search = search.parentNode!) { if (search == this.dom) { atEnd = true; break } if (search.nextSibling) break } } return (atEnd == null ? bias > 0 : atEnd) ? this.posAtEnd : this.posAtStart } // Scan up the dom finding the first desc that is a descendant of // this one. nearestDesc(dom: DOMNode): ViewDesc | undefined nearestDesc(dom: DOMNode, onlyNodes: true): NodeViewDesc | undefined nearestDesc(dom: DOMNode, onlyNodes: boolean = false) { for (let first = true, cur: DOMNode | null = dom; cur; cur = cur.parentNode) { let desc = this.getDesc(cur), nodeDOM if (desc && (!onlyNodes || desc.node)) { // If dom is outside of this desc's nodeDOM, don't count it. if (first && (nodeDOM = (desc as NodeViewDesc).nodeDOM) && !(isElt(nodeDOM) ? contains(nodeDOM, dom) : nodeDOM == dom)) first = false else return desc } } } getDesc(dom: DOMNode) { let desc = dom.pmViewDesc for (let cur: ViewDesc | undefined = desc; cur; cur = cur.parent) if (cur == this) return desc } posFromDOM(dom: DOMNode, offset: number, bias: number) { for (let scan: DOMNode | null = dom; scan; scan = scan.parentNode) { let desc = this.getDesc(scan) if (desc) return desc.localPosFromDOM(dom, offset, bias) } return -1 } // Find the desc for the node after the given pos, if any. (When a // parent node overrode rendering, there might not be one.) descAt(pos: number): ViewDesc | undefined { for (let i = 0, offset = 0; i < this.children.length; i++) { let child = this.children[i], end = offset + child.size if (offset == pos && end != offset) { while (!child.border && child.children.length) { for (let i = 0; i < child.children.length; i++) { let inner = child.children[i] if (inner.size) { child = inner; break } } } return child } if (pos < end) return child.descAt(pos - offset - child.border) offset = end } } domFromPos(pos: number, side: number): {node: DOMNode, offset: number, atom?: number} { if (!this.contentDOM) return {node: this.dom, offset: 0, atom: pos + 1} // First find the position in the child array let i = 0, offset = 0 for (let curPos = 0; i < this.children.length; i++) { let child = this.children[i], end = curPos + child.size if (end > pos) { offset = pos - curPos; break } curPos = end } // If this points into the middle of a child, call through if (offset) return this.children[i].domFromPos(offset - this.children[i].border, side) // Go back if there were any zero-length widgets with side >= 0 before this point for (; i > 0; i--) { let prev = this.children[i - 1] if (prev.size || !(prev instanceof BufferHackViewDesc || prev instanceof WidgetViewDesc && prev.side >= 0)) break } // Scan towards the first useable node if (side <= 0) { let prev, enter = true for (;; i--, enter = false) { prev = i ? this.children[i - 1] : null if (!prev || prev.dom.parentNode == this.contentDOM) break } if (prev && side && enter && !prev.border && !prev.domAtom) return prev.domFromPos(prev.size, side) return {node: this.contentDOM, offset: prev ? domIndex(prev.dom) + 1 : 0} } else { let next, enter = true for (;; i++, enter = false) { next = i < this.children.length ? this.children[i] : null if (!next || next.dom.parentNode == this.contentDOM) break } if (next && enter && !next.border && !next.domAtom) return next.domFromPos(0, side) return {node: this.contentDOM, offset: next ? domIndex(next.dom) : this.contentDOM.childNodes.length} } } // Used to find a DOM range in a single parent for a given changed // range. parseRange( from: number, to: number, base = 0 ): {node: DOMNode, from: number, to: number, fromOffset: number, toOffset: number} { if (this.children.length == 0) return {node: this.contentDOM!, from, to, fromOffset: 0, toOffset: this.contentDOM!.childNodes.length} let fromOffset = -1, toOffset = -1 for (let offset = base, i = 0;; i++) { let child = this.children[i], end = offset + child.size if (fromOffset == -1 && from <= end) { let childBase = offset + child.border // FIXME maybe descend mark views to parse a narrower range? if (from >= childBase && to <= end - child.border && child.node && child.contentDOM && this.contentDOM!.contains(child.contentDOM)) return child.parseRange(from, to, childBase) from = offset for (let j = i; j > 0; j--) { let prev = this.children[j - 1] if (prev.size && prev.dom.parentNode == this.contentDOM && !prev.emptyChildAt(1)) { fromOffset = domIndex(prev.dom) + 1 break } from -= prev.size } if (fromOffset == -1) fromOffset = 0 } if (fromOffset > -1 && (end > to || i == this.children.length - 1)) { to = end for (let j = i + 1; j < this.children.length; j++) { let next = this.children[j] if (next.size && next.dom.parentNode == this.contentDOM && !next.emptyChildAt(-1)) { toOffset = domIndex(next.dom) break } to += next.size } if (toOffset == -1) toOffset = this.contentDOM!.childNodes.length break } offset = end } return {node: this.contentDOM!, from, to, fromOffset, toOffset} } emptyChildAt(side: number): boolean { if (this.border || !this.contentDOM || !this.children.length) return false let child = this.children[side < 0 ? 0 : this.children.length - 1] return child.size == 0 || child.emptyChildAt(side) } domAfterPos(pos: number): DOMNode { let {node, offset} = this.domFromPos(pos, 0) if (!isElt(node) || offset == node.childNodes.length) throw new RangeError("No node after pos " + pos) return node.childNodes[offset] } // View descs are responsible for setting any selection that falls // entirely inside of them, so that custom implementations can do // custom things with the selection. Note that this falls apart when // a selection starts in such a node and ends in another, in which // case we just use whatever domFromPos produces as a best effort. setSelection(anchor: number, head: number, view: EditorView, force = false): void { // If the selection falls entirely in a child, give it to that child let from = Math.min(anchor, head), to = Math.max(anchor, head) for (let i = 0, offset = 0; i < this.children.length; i++) { let child = this.children[i], end = offset + child.size if (from > offset && to < end) return child.setSelection(anchor - offset - child.border, head - offset - child.border, view, force) offset = end } let anchorDOM = this.domFromPos(anchor, anchor ? -1 : 1) let headDOM = head == anchor ? anchorDOM : this.domFromPos(head, head ? -1 : 1) let domSel = (view.root as Document).getSelection()! let selRange = view.domSelectionRange() let brKludge = false // On Firefox, using Selection.collapse to put the cursor after a // BR node for some reason doesn't always work (#1073). On Safari, // the cursor sometimes inexplicable visually lags behind its // reported position in such situations (#1092). if ((browser.gecko || browser.safari) && anchor == head) { let {node, offset} = anchorDOM if (isText(node)) { brKludge = !!(offset && node.nodeValue![offset - 1] == "\n") // Issue #1128 if (brKludge && offset == node.nodeValue!.length) { for (let scan: DOMNode | null = node, after; scan; scan = scan.parentNode) { if (after = scan.nextSibling) { if (after.nodeName == "BR") anchorDOM = headDOM = {node: after.parentNode!, offset: domIndex(after) + 1} break } let desc = scan.pmViewDesc if (desc && desc.node && desc.node.isBlock) break } } } else { let prev = node.childNodes[offset - 1] brKludge = prev && (prev.nodeName == "BR" || (prev as HTMLElement).contentEditable == "false") } } // Firefox can act strangely when the selection is in front of an // uneditable node. See #1163 and https://bugzilla.mozilla.org/show_bug.cgi?id=1709536 if (browser.gecko && selRange.focusNode && selRange.focusNode != headDOM.node && isElt(selRange.focusNode)) { let after = selRange.focusNode.childNodes[selRange.focusOffset] if (after && (after as HTMLElement).contentEditable == "false") force = true } if (!(force || brKludge && browser.safari) && isEquivalentPosition(anchorDOM.node, anchorDOM.offset, selRange.anchorNode!, selRange.anchorOffset) && isEquivalentPosition(headDOM.node, headDOM.offset, selRange.focusNode!, selRange.focusOffset)) return // 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. let domSelExtended = false if ((domSel.extend || anchor == head) && !(brKludge && browser.gecko)) { domSel.collapse(anchorDOM.node, anchorDOM.offset) try { if (anchor != head) domSel.extend(headDOM.node, headDOM.offset) domSelExtended = true } catch (_) { // In some cases with Chrome the selection is empty after calling // collapse, even when it should be valid. This appears to be a bug, but // it is difficult to isolate. If this happens fallback to the old path // without using extend. // Similarly, this could crash on Safari if the editor is hidden, and // there was no selection. } } if (!domSelExtended) { if (anchor > head) { let tmp = anchorDOM; anchorDOM = headDOM; headDOM = tmp } let range = document.createRange() range.setEnd(headDOM.node, headDOM.offset) range.setStart(anchorDOM.node, anchorDOM.offset) domSel.removeAllRanges() domSel.addRange(range) } } ignoreMutation(mutation: ViewMutationRecord): boolean { return !this.contentDOM && mutation.type != "selection" } get contentLost() { return this.contentDOM && this.contentDOM != this.dom && !this.dom.contains(this.contentDOM) } // Remove a subtree of the element tree that has been touched // by a DOM change, so that the next update will redraw it. markDirty(from: number, to: number) { for (let offset = 0, i = 0; i < this.children.length; i++) { let child = this.children[i], end = offset + child.size if (offset == end ? from <= end && to >= offset : from < end && to > offset) { let startInside = offset + child.border, endInside = end - child.border if (from >= startInside && to <= endInside) { this.dirty = from == offset || to == end ? CONTENT_DIRTY : CHILD_DIRTY if (from == startInside && to == endInside && (child.contentLost || child.dom.parentNode != this.contentDOM)) child.dirty = NODE_DIRTY else child.markDirty(from - startInside, to - startInside) return } else { child.dirty = child.dom == child.contentDOM && child.dom.parentNode == this.contentDOM && !child.children.length ? CONTENT_DIRTY : NODE_DIRTY } } offset = end } this.dirty = CONTENT_DIRTY } markParentsDirty() { let level = 1 for (let node = this.parent; node; node = node.parent, level++) { let dirty = level == 1 ? CONTENT_DIRTY : CHILD_DIRTY if (node.dirty < dirty) node.dirty = dirty } } get domAtom() { return false } get ignoreForCoords() { return false } get ignoreForSelection() { return false } isText(text: string) { return false } } // A widget desc represents a widget decoration, which is a DOM node // drawn between the document nodes. class WidgetViewDesc extends ViewDesc { constructor(parent: ViewDesc, readonly widget: Decoration, view: EditorView, pos: number) { let self: WidgetViewDesc, dom = (widget.type as any).toDOM as WidgetConstructor if (typeof dom == "function") dom = dom(view, () => { if (!self) return pos if (self.parent) return self.parent.posBeforeChild(self) }) if (!widget.type.spec.raw) { let elt = isElt(dom) ? dom : document.createElement("span") if (elt != dom) { elt.appendChild(dom); dom = elt } if (!elt.hasAttribute("contenteditable")) elt.contentEditable = "false" elt.classList.add("ProseMirror-widget") } super(parent, [], dom, null) this.widget = widget self = this } matchesWidget(widget: Decoration) { return this.dirty == NOT_DIRTY && widget.type.eq(this.widget.type) } parseRule() { return {ignore: true} } stopEvent(event: Event) { let stop = this.widget.spec.stopEvent return stop ? stop(event) : false } ignoreMutation(mutation: ViewMutationRecord) { return mutation.type != "selection" || this.widget.spec.ignoreSelection } destroy() { this.widget.type.destroy(this.dom) super.destroy() } get domAtom() { return true } get ignoreForSelection() { return !!this.widget.type.spec.relaxedSide } get side() { return (this.widget.type as any).side as number } } class CompositionViewDesc extends ViewDesc { constructor(parent: ViewDesc, dom: DOMNode, readonly textDOM: Text, readonly text: string) { super(parent, [], dom, null) } get size() { return this.text.length } localPosFromDOM(dom: DOMNode, offset: number) { if (dom != this.textDOM) return this.posAtStart + (offset ? this.size : 0) return this.posAtStart + offset } domFromPos(pos: number) { return {node: this.textDOM, offset: pos} } ignoreMutation(mut: ViewMutationRecord) { return mut.type === 'characterData' && mut.target.nodeValue == mut.oldValue } } // A mark desc represents a mark. May have multiple children, // depending on how the mark is split. Note that marks are drawn using // a fixed nesting order, for simplicity and predictability, so in // some cases they will be split more often than would appear // necessary. class MarkViewDesc extends ViewDesc { constructor(parent: ViewDesc, public mark: Mark, dom: HTMLElement, contentDOM: HTMLElement, readonly spec: MarkView) { super(parent, [], dom, contentDOM) } static create(parent: ViewDesc, mark: Mark, inline: boolean, view: EditorView) { let custom = view.nodeViews[mark.type.name] let spec: {dom: HTMLElement, contentDOM?: HTMLElement} = custom && (custom as any)(mark, view, inline) if (!spec || !spec.dom) spec = (DOMSerializer.renderSpec as any)(document, mark.type.spec.toDOM!(mark, inline), null, mark.attrs) as any return new MarkViewDesc(parent, mark, spec.dom, spec.contentDOM || spec.dom as HTMLElement, spec) } parseRule() { if ((this.dirty & NODE_DIRTY) || this.mark.type.spec.reparseInView) return null return {mark: this.mark.type.name, attrs: this.mark.attrs, contentElement: this.contentDOM!} } matchesMark(mark: Mark) { return this.dirty != NODE_DIRTY && this.mark.eq(mark) } markDirty(from: number, to: number) { super.markDirty(from, to) // Move dirty info to nearest node view if (this.dirty != NOT_DIRTY) { let parent = this.parent! while (!parent.node) parent = parent.parent! if (parent.dirty < this.dirty) parent.dirty = this.dirty this.dirty = NOT_DIRTY } } slice(from: number, to: number, view: EditorView) { let copy = MarkViewDesc.create(this.parent!, this.mark, true, view) let nodes = this.children, size = this.size if (to < size) nodes = replaceNodes(nodes, to, size, view) if (from > 0) nodes = replaceNodes(nodes, 0, from, view) for (let i = 0; i < nodes.length; i++) nodes[i].parent = copy copy.children = nodes return copy } ignoreMutation(mutation: ViewMutationRecord) { return this.spec.ignoreMutation ? this.spec.ignoreMutation(mutation) : super.ignoreMutation(mutation) } destroy() { if (this.spec.destroy) this.spec.destroy() super.destroy() } } // Node view descs are the main, most common type of view desc, and // correspond to an actual node in the document. Unlike mark descs, // they populate their child array themselves. export class NodeViewDesc extends ViewDesc { constructor( parent: ViewDesc | undefined, public node: Node, public outerDeco: readonly Decoration[], public innerDeco: DecorationSource, dom: DOMNode, contentDOM: HTMLElement | null, readonly nodeDOM: DOMNode, ) { super(parent, [], dom, contentDOM) } // By default, a node is rendered using the `toDOM` method from the // node type spec. But client code can use the `nodeViews` spec to // supply a custom node view, which can influence various aspects of // the way the node works. // // (Using subclassing for this was intentionally decided against, // since it'd require exposing a whole slew of finicky // implementation details to the user code that they probably will // never need.) static create(parent: ViewDesc | undefined, node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView, pos: number) { let custom = view.nodeViews[node.type.name], descObj: ViewDesc let spec: NodeView | undefined = custom && (custom as any)(node, view, () => { // (This is a function that allows the custom view to find its // own position) if (!descObj) return pos if (descObj.parent) return descObj.parent.posBeforeChild(descObj) }, outerDeco, innerDeco) let dom: DOMNode | undefined = spec && spec.dom, contentDOM = spec && spec.contentDOM if (node.isText) { if (!dom) dom = document.createTextNode(node.text!) else if (!isText(dom)) throw new RangeError("Text must be rendered as a DOM text node") } else if (!dom) { let spec = (DOMSerializer.renderSpec as any)(document, node.type.spec.toDOM!(node), null, node.attrs) ;({dom, contentDOM} = spec as {dom: HTMLElement, contentDOM?: HTMLElement}) } if (!contentDOM && !node.isText && dom.nodeName != "BR") { // Chrome gets confused by
    if (!(dom as HTMLElement).hasAttribute("contenteditable")) (dom as HTMLElement).contentEditable = "false" if (node.type.spec.draggable) (dom as HTMLElement).draggable = true } let nodeDOM = dom dom = applyOuterDeco(dom, outerDeco, node) if (spec) return descObj = new CustomNodeViewDesc(parent, node, outerDeco, innerDeco, dom as HTMLElement, contentDOM || null, nodeDOM as HTMLElement, spec) else if (node.isText) return new TextViewDesc(parent, node, outerDeco, innerDeco, dom, nodeDOM) else return new NodeViewDesc(parent, node, outerDeco, innerDeco, dom, contentDOM || null, nodeDOM) } parseRule(addedNodes?: readonly DOMNode[]) { // Experimental kludge to allow opt-in re-parsing of nodes if (this.node.type.spec.reparseInView) return null // FIXME the assumption that this can always return the current // attrs means that if the user somehow manages to change the // attrs in the dom, that won't be picked up. Not entirely sure // whether this is a problem let rule: Omit = {node: this.node.type.name, attrs: this.node.attrs} if (this.node.type.whitespace == "pre") rule.preserveWhitespace = "full" if (!this.contentDOM) { rule.getContent = () => this.node.content } else if (!this.contentLost) { rule.contentElement = this.contentDOM } else { // Chrome likes to randomly recreate parent nodes when // backspacing things. When that happens, this tries to find the // new parent. for (let i = this.children.length - 1; i >= 0; i--) { let child = this.children[i] if (this.dom.contains(child.dom.parentNode)) { rule.contentElement = child.dom.parentNode as HTMLElement break } } if (!rule.contentElement) { let found = addedNodes && addedNodes.find(n => isElt(n) && addedNodes.indexOf(n.parentNode!) < 0 && this.dom.contains(n)) if (found) rule.contentElement = found as HTMLElement else rule.getContent = () => Fragment.empty } } return rule } matchesNode(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource) { return this.dirty == NOT_DIRTY && node.eq(this.node) && sameOuterDeco(outerDeco, this.outerDeco) && innerDeco.eq(this.innerDeco) } get size() { return this.node.nodeSize } get border() { return this.node.isLeaf ? 0 : 1 } // Syncs `this.children` to match `this.node.content` and the local // decorations, possibly introducing nesting for marks. Then, in a // separate step, syncs the DOM inside `this.contentDOM` to // `this.children`. updateChildren(view: EditorView, pos: number) { let inline = this.node.inlineContent, off = pos let composition = view.composing ? this.localCompositionInfo(view, pos) : null let localComposition = composition && composition.pos > -1 ? composition : null let compositionInChild = composition && composition.pos < 0 let updater = new ViewTreeUpdater(this, localComposition && localComposition.node, view) let addBuffers = this.node.inlineContent, atBufferPos = addBuffers && this.node.isBlock iterDeco(this.node, this.innerDeco, (widget, i, insideNode) => { if (widget.spec.marks) updater.syncToMarks(widget.spec.marks, inline, view, i) else if ((widget.type as WidgetType).side >= 0 && !insideNode) updater.syncToMarks(i == this.node.childCount ? Mark.none : this.node.child(i).marks, inline, view, i) // If the next node is a desc matching this widget, reuse it, // otherwise insert the widget as a new view desc. updater.placeWidget(widget, view, off) }, (child, outerDeco, innerDeco, i) => { // Make sure the wrapping mark descs match the node's marks. updater.syncToMarks(child.marks, inline, view, i) if (atBufferPos && child.isAtom && !child.isText) updater.addHackNode("IMG", updater.top) // Try several strategies for drawing this node let compIndex if (updater.findNodeMatch(child, outerDeco, innerDeco, i)) { // Found precise match with existing node view } else if (compositionInChild && view.state.selection.from > off && view.state.selection.to < off + child.nodeSize && (compIndex = updater.findIndexWithChild(composition!.node)) > -1 && updater.updateNodeAt(child, outerDeco, innerDeco, compIndex, view)) { // Updated the specific node that holds the composition } else if (updater.updateNextNode(child, outerDeco, innerDeco, view, i, off)) { // Could update an existing node to reflect this node } else { // Add it as a new view updater.addNode(child, outerDeco, innerDeco, view, off) } off += child.nodeSize if (addBuffers) atBufferPos = child.isAtom && !child.isText }) // Drop all remaining descs after the current position. updater.syncToMarks([], inline, view, 0) if (this.node.isTextblock) updater.addTextblockHacks() updater.destroyRest() // Sync the DOM if anything changed if (updater.changed || this.dirty == CONTENT_DIRTY) { // May have to protect focused DOM from being changed if a composition is active if (localComposition) this.protectLocalComposition(view, localComposition) renderDescs(this.contentDOM!, this.children, view) if (browser.ios) iosHacks(this.dom as HTMLElement) } } localCompositionInfo(view: EditorView, pos: number): {node: Text, pos: number, text: string} | null { // Only do something if both the selection and a focused text node // are inside of this node let {from, to} = view.state.selection if (!(view.state.selection instanceof TextSelection) || from < pos || to > pos + this.node.content.size) return null let textNode = view.input.compositionNode if (!textNode || !this.dom.contains(textNode.parentNode)) return null if (this.node.inlineContent) { // Find the text in the focused node in the node, stop if it's not // there (may have been modified through other means, in which // case it should overwritten) let text = textNode.nodeValue! let textPos = findTextInFragment(this.node.content, text, from - pos, to - pos) return textPos < 0 ? null : {node: textNode, pos: textPos, text} } else { return {node: textNode, pos: -1, text: ""} } } protectLocalComposition(view: EditorView, {node, pos, text}: {node: Text, pos: number, text: string}) { // The node is already part of a local view desc, leave it there if (this.getDesc(node)) return // Create a composition view for the orphaned nodes let topNode: DOMNode = node for (;; topNode = topNode.parentNode!) { if (topNode.parentNode == this.contentDOM) break while (topNode.previousSibling) topNode.parentNode!.removeChild(topNode.previousSibling) while (topNode.nextSibling) topNode.parentNode!.removeChild(topNode.nextSibling) if (topNode.pmViewDesc) topNode.pmViewDesc = undefined } let desc = new CompositionViewDesc(this, topNode, node, text) view.input.compositionNodes.push(desc) // Patch up this.children to contain the composition view this.children = replaceNodes(this.children, pos, pos + text.length, view, desc) } // If this desc must be updated to match the given node decoration, // do so and return true. update(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView) { if (this.dirty == NODE_DIRTY || !node.sameMarkup(this.node)) return false this.updateInner(node, outerDeco, innerDeco, view) return true } updateInner(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView) { this.updateOuterDeco(outerDeco) this.node = node this.innerDeco = innerDeco if (this.contentDOM) this.updateChildren(view, this.posAtStart) this.dirty = NOT_DIRTY } updateOuterDeco(outerDeco: readonly Decoration[]) { if (sameOuterDeco(outerDeco, this.outerDeco)) return let needsWrap = !isElt(this.nodeDOM) let oldDOM = this.dom this.dom = patchOuterDeco(this.dom, this.nodeDOM, computeOuterDeco(this.outerDeco, this.node, needsWrap), computeOuterDeco(outerDeco, this.node, needsWrap)) if (this.dom != oldDOM) { oldDOM.pmViewDesc = undefined this.dom.pmViewDesc = this } this.outerDeco = outerDeco } // Mark this node as being the selected node. selectNode() { if (isElt(this.nodeDOM)) { this.nodeDOM.classList.add("ProseMirror-selectednode") if (this.contentDOM || !this.node.type.spec.draggable) this.nodeDOM.draggable = true } } // Remove selected node marking from this node. deselectNode() { if (isElt(this.nodeDOM)) { this.nodeDOM.classList.remove("ProseMirror-selectednode") if (this.contentDOM || !this.node.type.spec.draggable) this.nodeDOM.removeAttribute("draggable") } } get domAtom() { return this.node.isAtom } } // Create a view desc for the top-level document node, to be exported // and used by the view class. export function docViewDesc(doc: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, dom: HTMLElement, view: EditorView): NodeViewDesc { applyOuterDeco(dom, outerDeco, doc) let docView = new NodeViewDesc(undefined, doc, outerDeco, innerDeco, dom, dom, dom) if (docView.contentDOM) docView.updateChildren(view, 0) return docView } class TextViewDesc extends NodeViewDesc { constructor(parent: ViewDesc | undefined, node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, dom: DOMNode, nodeDOM: DOMNode) { super(parent, node, outerDeco, innerDeco, dom, null, nodeDOM) } parseRule() { let skip = this.nodeDOM.parentNode while (skip && skip != this.dom && !(skip as any).pmIsDeco) skip = skip.parentNode return {skip: (skip || true) as any} } update(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView) { if (this.dirty == NODE_DIRTY || (this.dirty != NOT_DIRTY && !this.inParent()) || !node.sameMarkup(this.node)) return false this.updateOuterDeco(outerDeco) if ((this.dirty != NOT_DIRTY || node.text != this.node.text) && node.text != this.nodeDOM.nodeValue) { this.nodeDOM.nodeValue = node.text! if (view.trackWrites == this.nodeDOM) view.trackWrites = null } this.node = node this.dirty = NOT_DIRTY return true } inParent() { let parentDOM = this.parent!.contentDOM for (let n: DOMNode | null = this.nodeDOM; n; n = n.parentNode) if (n == parentDOM) return true return false } domFromPos(pos: number) { return {node: this.nodeDOM, offset: pos} } localPosFromDOM(dom: DOMNode, offset: number, bias: number) { if (dom == this.nodeDOM) return this.posAtStart + Math.min(offset, this.node.text!.length) return super.localPosFromDOM(dom, offset, bias) } ignoreMutation(mutation: ViewMutationRecord) { return mutation.type != "characterData" && mutation.type != "selection" } slice(from: number, to: number, _view?: EditorView) { let node = this.node.cut(from, to), dom = document.createTextNode(node.text!) return new TextViewDesc(this.parent, node, this.outerDeco, this.innerDeco, dom, dom) } markDirty(from: number, to: number) { super.markDirty(from, to) if (this.dom != this.nodeDOM && (from == 0 || to == this.nodeDOM.nodeValue!.length)) this.dirty = NODE_DIRTY } get domAtom() { return false } isText(text: string) { return this.node.text == text } } // A dummy desc used to tag extra BR or IMG nodes created to work // around contentEditable terribleness. class BufferHackViewDesc extends ViewDesc { parseRule() { return {ignore: true} } matchesHack(nodeName: string) { return this.dirty == NOT_DIRTY && this.dom.nodeName == nodeName } get domAtom() { return true } get ignoreForCoords() { return this.dom.nodeName == "IMG" } } // A separate subclass is used for customized node views, so that the // extra checks only have to be made for nodes that are actually // customized. class CustomNodeViewDesc extends NodeViewDesc { constructor(parent: ViewDesc | undefined, node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, dom: HTMLElement, contentDOM: HTMLElement | null, nodeDOM: HTMLElement, readonly spec: NodeView) { super(parent, node, outerDeco, innerDeco, dom, contentDOM, nodeDOM) } // A custom `update` method gets to decide whether the update goes // through. If it does, and there's a `contentDOM` node, our logic // updates the children. update(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView) { if (this.dirty == NODE_DIRTY) return false if (this.spec.update && (this.node.type == node.type || this.spec.multiType)) { let result = this.spec.update(node, outerDeco, innerDeco) if (result) this.updateInner(node, outerDeco, innerDeco, view) return result } else if (!this.contentDOM && !node.isLeaf) { return false } else { return super.update(node, outerDeco, innerDeco, view) } } selectNode() { this.spec.selectNode ? this.spec.selectNode() : super.selectNode() } deselectNode() { this.spec.deselectNode ? this.spec.deselectNode() : super.deselectNode() } setSelection(anchor: number, head: number, view: EditorView, force: boolean) { this.spec.setSelection ? this.spec.setSelection(anchor, head, view.root) : super.setSelection(anchor, head, view, force) } destroy() { if (this.spec.destroy) this.spec.destroy() super.destroy() } stopEvent(event: Event) { return this.spec.stopEvent ? this.spec.stopEvent(event) : false } ignoreMutation(mutation: ViewMutationRecord) { return this.spec.ignoreMutation ? this.spec.ignoreMutation(mutation) : super.ignoreMutation(mutation) } } // Sync the content of the given DOM node with the nodes associated // with the given array of view descs, recursing into mark descs // because this should sync the subtree for a whole node at a time. function renderDescs(parentDOM: HTMLElement, descs: readonly ViewDesc[], view: EditorView) { let dom = parentDOM.firstChild, written = false for (let i = 0; i < descs.length; i++) { let desc = descs[i], childDOM = desc.dom if (childDOM.parentNode == parentDOM) { while (childDOM != dom) { dom = rm(dom!); written = true } dom = dom.nextSibling } else { written = true parentDOM.insertBefore(childDOM, dom) } if (desc instanceof MarkViewDesc) { let pos = dom ? dom.previousSibling : parentDOM.lastChild renderDescs(desc.contentDOM!, desc.children, view) dom = pos ? pos.nextSibling : parentDOM.firstChild } } while (dom) { dom = rm(dom); written = true } if (written && view.trackWrites == parentDOM) view.trackWrites = null } type OuterDecoLevel = {[attr: string]: string} const OuterDecoLevel: {new (nodeName?: string): OuterDecoLevel} = function(this: any, nodeName?: string) { if (nodeName) this.nodeName = nodeName } as any OuterDecoLevel.prototype = Object.create(null) const noDeco = [new OuterDecoLevel] function computeOuterDeco(outerDeco: readonly Decoration[], node: Node, needsWrap: boolean) { if (outerDeco.length == 0) return noDeco let top = needsWrap ? noDeco[0] : new OuterDecoLevel, result = [top] for (let i = 0; i < outerDeco.length; i++) { let attrs = (outerDeco[i].type as NodeType).attrs if (!attrs) continue if (attrs.nodeName) result.push(top = new OuterDecoLevel(attrs.nodeName)) for (let name in attrs) { let val = attrs[name] if (val == null) continue if (needsWrap && result.length == 1) result.push(top = new OuterDecoLevel(node.isInline ? "span" : "div")) if (name == "class") top.class = (top.class ? top.class + " " : "") + val else if (name == "style") top.style = (top.style ? top.style + ";" : "") + val else if (name != "nodeName") top[name] = val } } return result } function patchOuterDeco(outerDOM: DOMNode, nodeDOM: DOMNode, prevComputed: readonly OuterDecoLevel[], curComputed: readonly OuterDecoLevel[]) { // Shortcut for trivial case if (prevComputed == noDeco && curComputed == noDeco) return nodeDOM let curDOM = nodeDOM for (let i = 0; i < curComputed.length; i++) { let deco = curComputed[i], prev = prevComputed[i] if (i) { let parent: DOMNode | null if (prev && prev.nodeName == deco.nodeName && curDOM != outerDOM && (parent = curDOM.parentNode) && parent.nodeName!.toLowerCase() == deco.nodeName) { curDOM = parent } else { parent = document.createElement(deco.nodeName) ;(parent as any).pmIsDeco = true parent.appendChild(curDOM) prev = noDeco[0] curDOM = parent } } patchAttributes(curDOM as HTMLElement, prev || noDeco[0], deco) } return curDOM } function patchAttributes(dom: HTMLElement, prev: {[name: string]: string}, cur: {[name: string]: string}) { for (let name in prev) if (name != "class" && name != "style" && name != "nodeName" && !(name in cur)) dom.removeAttribute(name) for (let name in cur) if (name != "class" && name != "style" && name != "nodeName" && cur[name] != prev[name]) dom.setAttribute(name, cur[name]) if (prev.class != cur.class) { let prevList = prev.class ? prev.class.split(" ").filter(Boolean) : [] let curList = cur.class ? cur.class.split(" ").filter(Boolean) : [] for (let i = 0; i < prevList.length; i++) if (curList.indexOf(prevList[i]) == -1) dom.classList.remove(prevList[i]) for (let i = 0; i < curList.length; i++) if (prevList.indexOf(curList[i]) == -1) dom.classList.add(curList[i]) if (dom.classList.length == 0) dom.removeAttribute("class") } if (prev.style != cur.style) { if (prev.style) { let prop = /\s*([\w\-\xa1-\uffff]+)\s*:(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\(.*?\)|[^;])*/g, m while (m = prop.exec(prev.style)) dom.style.removeProperty(m[1]) } if (cur.style) dom.style.cssText += cur.style } } function applyOuterDeco(dom: DOMNode, deco: readonly Decoration[], node: Node) { return patchOuterDeco(dom, dom, noDeco, computeOuterDeco(deco, node, !isElt(dom))) } function sameOuterDeco(a: readonly Decoration[], b: readonly Decoration[]) { if (a.length != b.length) return false for (let i = 0; i < a.length; i++) if (!a[i].type.eq(b[i].type)) return false return true } // Remove a DOM node and return its next sibling. function rm(dom: DOMNode) { let next = dom.nextSibling dom.parentNode!.removeChild(dom) return next } // Helper class for incrementally updating a tree of mark descs and // the widget and node descs inside of them. class ViewTreeUpdater { // Index into `this.top`'s child array, represents the current // update position. index = 0 // When entering a mark, the current top and index are pushed // onto this. stack: (ViewDesc | number)[] = [] // Tracks whether anything was changed changed = false preMatch: {index: number, matched: Map, matches: readonly ViewDesc[]} top: ViewDesc constructor(top: NodeViewDesc, readonly lock: DOMNode | null, private readonly view: EditorView) { this.top = top this.preMatch = preMatch(top.node.content, top) } // Destroy and remove the children between the given indices in // `this.top`. destroyBetween(start: number, end: number) { if (start == end) return for (let i = start; i < end; i++) this.top.children[i].destroy() this.top.children.splice(start, end - start) this.changed = true } // Destroy all remaining children in `this.top`. destroyRest() { this.destroyBetween(this.index, this.top.children.length) } // Sync the current stack of mark descs with the given array of // marks, reusing existing mark descs when possible. syncToMarks(marks: readonly Mark[], inline: boolean, view: EditorView, parentIndex: number) { let keep = 0, depth = this.stack.length >> 1 let maxKeep = Math.min(depth, marks.length) while (keep < maxKeep && (keep == depth - 1 ? this.top : this.stack[(keep + 1) << 1] as ViewDesc) .matchesMark(marks[keep]) && marks[keep].type.spec.spanning !== false) keep++ while (keep < depth) { this.destroyRest() this.top.dirty = NOT_DIRTY this.index = this.stack.pop() as number this.top = this.stack.pop() as ViewDesc depth-- } while (depth < marks.length) { this.stack.push(this.top, this.index + 1) let found = -1, scanTo = this.top.children.length if (parentIndex < this.preMatch.index) scanTo = Math.min(this.index + 3, scanTo) for (let i = this.index; i < scanTo; i++) { let next = this.top.children[i] if (next.matchesMark(marks[depth]) && !this.isLocked(next.dom)) { found = i; break } } // When nothing matches, try to update the mark view at this position // in place, so a custom mark view can adapt to a changed mark without // re-creating its DOM. if (found < 0 && this.index < this.top.children.length) { let cur = this.top.children[this.index] if (cur instanceof MarkViewDesc && cur.dirty != NODE_DIRTY && cur.mark.type == marks[depth].type && cur.spec.update && !this.isLocked(cur.dom) && cur.spec.update(marks[depth])) { cur.mark = marks[depth] found = this.index this.changed = true } } if (found > -1) { if (found > this.index) { this.changed = true this.destroyBetween(this.index, found) } this.top = this.top.children[this.index] } else { let markDesc = MarkViewDesc.create(this.top, marks[depth], inline, view) this.top.children.splice(this.index, 0, markDesc) this.top = markDesc this.changed = true } this.index = 0 depth++ } } // Try to find a node desc matching the given data. Skip over it and // return true when successful. findNodeMatch(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, index: number): boolean { let found = -1, targetDesc if (index >= this.preMatch.index && (targetDesc = this.preMatch.matches[index - this.preMatch.index]).parent == this.top && targetDesc.matchesNode(node, outerDeco, innerDeco)) { found = this.top.children.indexOf(targetDesc, this.index) } else { for (let i = this.index, e = Math.min(this.top.children.length, i + 5); i < e; i++) { let child = this.top.children[i] if (child.matchesNode(node, outerDeco, innerDeco) && !this.preMatch.matched.has(child)) { found = i break } } } if (found < 0) return false this.destroyBetween(this.index, found) this.index++ return true } updateNodeAt(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, index: number, view: EditorView) { let child = this.top.children[index] as NodeViewDesc if (child.dirty == NODE_DIRTY && child.dom == child.contentDOM) child.dirty = CONTENT_DIRTY if (!child.update(node, outerDeco, innerDeco, view)) return false this.destroyBetween(this.index, index) this.index++ return true } findIndexWithChild(domNode: DOMNode) { for (;;) { let parent = domNode.parentNode if (!parent) return -1 if (parent == this.top.contentDOM) { let desc = domNode.pmViewDesc if (desc) for (let i = this.index; i < this.top.children.length; i++) { if (this.top.children[i] == desc) return i } return -1 } domNode = parent } } // Try to update the next node, if any, to the given data. Checks // pre-matches to avoid overwriting nodes that could still be used. updateNextNode(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView, index: number, pos: number): boolean { for (let i = this.index; i < this.top.children.length; i++) { let next = this.top.children[i] if (next instanceof NodeViewDesc) { let preMatch = this.preMatch.matched.get(next) if (preMatch != null && preMatch != index) return false let nextDOM = next.dom, updated // Can't update if nextDOM is or contains this.lock, except if // it's a text node whose content already matches the new text // and whose decorations match the new ones. let locked = this.isLocked(nextDOM) && !(node.isText && next.node && next.node.isText && next.nodeDOM.nodeValue == node.text && next.dirty != NODE_DIRTY && sameOuterDeco(outerDeco, next.outerDeco)) if (!locked && next.update(node, outerDeco, innerDeco, view)) { this.destroyBetween(this.index, i) if (next.dom != nextDOM) this.changed = true this.index++ return true } else if (!locked && (updated = this.recreateWrapper(next, node, outerDeco, innerDeco, view, pos))) { this.destroyBetween(this.index, i) this.top.children[this.index] = updated if (updated.contentDOM) { updated.dirty = CONTENT_DIRTY updated.updateChildren(view, pos + 1) updated.dirty = NOT_DIRTY } this.changed = true this.index++ return true } break } } return false } // When a node with content is replaced by a different node with // identical content, move over its children. recreateWrapper(next: NodeViewDesc, node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView, pos: number) { if (next.dirty || node.isAtom || !next.children.length || !next.node.content.eq(node.content) || !sameOuterDeco(outerDeco, next.outerDeco) || !innerDeco.eq(next.innerDeco)) return null let wrapper = NodeViewDesc.create(this.top, node, outerDeco, innerDeco, view, pos) if (wrapper.contentDOM) { wrapper.children = next.children next.children = [] for (let ch of wrapper.children) ch.parent = wrapper } next.destroy() return wrapper } // Insert the node as a newly created node desc. addNode(node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, view: EditorView, pos: number) { let desc = NodeViewDesc.create(this.top, node, outerDeco, innerDeco, view, pos) if (desc.contentDOM) desc.updateChildren(view, pos + 1) this.top.children.splice(this.index++, 0, desc) this.changed = true } placeWidget(widget: Decoration, view: EditorView, pos: number) { let next = this.index < this.top.children.length ? this.top.children[this.index] : null if (next && next.matchesWidget(widget) && (widget == (next as WidgetViewDesc).widget || !(next as any).widget.type.toDOM.parentNode)) { this.index++ } else { let desc = new WidgetViewDesc(this.top, widget, view, pos) this.top.children.splice(this.index++, 0, desc) this.changed = true } } // Make sure a textblock looks and behaves correctly in // contentEditable. addTextblockHacks() { let lastChild = this.top.children[this.index - 1], parent = this.top while (lastChild instanceof MarkViewDesc) { parent = lastChild lastChild = parent.children[parent.children.length - 1] } if (!lastChild || // Empty textblock !(lastChild instanceof TextViewDesc) || /\n$/.test(lastChild.node.text!) || (this.view.requiresGeckoHackNode && /\s$/.test(lastChild.node.text!))) { // Avoid bugs in Safari's cursor drawing (#1165) and Chrome's mouse selection (#1152) if ((browser.safari || browser.chrome) && lastChild && (lastChild.dom as HTMLElement).contentEditable == "false") this.addHackNode("IMG", parent) this.addHackNode("BR", this.top) } } addHackNode(nodeName: string, parent: ViewDesc) { if (parent == this.top && this.index < parent.children.length && parent.children[this.index].matchesHack(nodeName)) { this.index++ } else { let dom = document.createElement(nodeName) if (nodeName == "IMG") { dom.className = "ProseMirror-separator" ;(dom as HTMLImageElement).alt = "" } if (nodeName == "BR") dom.className = "ProseMirror-trailingBreak" let hack = new BufferHackViewDesc(this.top, [], dom, null) if (parent != this.top) parent.children.push(hack) else parent.children.splice(this.index++, 0, hack) this.changed = true } } isLocked(node: DOMNode) { return this.lock && (node == this.lock || isElt(node) && node.contains(this.lock.parentNode)) } } // Iterate from the end of the fragment and array of descs to find // directly matching ones, in order to avoid overeagerly reusing those // for other nodes. function preMatch(frag: Fragment, parentDesc: ViewDesc): { // The fragment index of the first node that is part of the sequence // of matched nodes at the end of the fragment. index: number, // A map from matched descs to fragment indices. matched: Map, // The matched descs. matches: readonly ViewDesc[] } { let curDesc = parentDesc, descI = curDesc.children.length let fI = frag.childCount, matched = new Map, matches = [] outer: while (fI > 0) { let desc for (;;) { if (descI) { let next = curDesc.children[descI - 1] if (next instanceof MarkViewDesc) { curDesc = next descI = next.children.length } else { desc = next descI-- break } } else if (curDesc == parentDesc) { break outer } else { descI = curDesc.parent!.children.indexOf(curDesc) curDesc = curDesc.parent! } } let node = desc.node if (!node) continue if (node != frag.child(fI - 1)) break --fI matched.set(desc, fI) matches.push(desc) } return {index: fI, matched, matches: matches.reverse()} } function compareSide(a: Decoration, b: Decoration) { return (a.type as WidgetType).side - (b.type as WidgetType).side } // This function abstracts iterating over the nodes and decorations in // a fragment. Calls `onNode` for each node, with its local and child // decorations. Splits text nodes when there is a decoration starting // or ending inside of them. Calls `onWidget` for each widget. function iterDeco( parent: Node, deco: DecorationSource, onWidget: (widget: Decoration, index: number, insideNode: boolean) => void, onNode: (node: Node, outerDeco: readonly Decoration[], innerDeco: DecorationSource, index: number) => void ) { let locals = deco.locals(parent), offset = 0 // Simple, cheap variant for when there are no local decorations if (locals.length == 0) { for (let i = 0; i < parent.childCount; i++) { let child = parent.child(i) onNode(child, locals, deco.forChild(offset, child), i) offset += child.nodeSize } return } let decoIndex = 0, active = [], restNode = null for (let parentIndex = 0;;) { let widget, widgets while (decoIndex < locals.length && locals[decoIndex].to == offset) { let next = locals[decoIndex++] if (next.widget) { if (!widget) widget = next else (widgets || (widgets = [widget])).push(next) } } if (widget) { if (widgets) { widgets.sort(compareSide) for (let i = 0; i < widgets.length; i++) onWidget(widgets[i], parentIndex, !!restNode) } else { onWidget(widget, parentIndex, !!restNode) } } let child, index if (restNode) { index = -1 child = restNode restNode = null } else if (parentIndex < parent.childCount) { index = parentIndex child = parent.child(parentIndex++) } else { break } for (let i = 0; i < active.length; i++) if (active[i].to <= offset) active.splice(i--, 1) while (decoIndex < locals.length && locals[decoIndex].from <= offset && locals[decoIndex].to > offset) active.push(locals[decoIndex++]) let end = offset + child.nodeSize if (child.isText) { let cutAt = end if (decoIndex < locals.length && locals[decoIndex].from < cutAt) cutAt = locals[decoIndex].from for (let i = 0; i < active.length; i++) if (active[i].to < cutAt) cutAt = active[i].to if (cutAt < end) { restNode = child.cut(cutAt - offset) child = child.cut(0, cutAt - offset) end = cutAt index = -1 } } else { while (decoIndex < locals.length && locals[decoIndex].to < end) decoIndex++ } let outerDeco = child.isInline && !child.isLeaf ? active.filter(d => !d.inline) : active.slice() onNode(child, outerDeco, deco.forChild(offset, child), index) offset = end } } // List markers in Mobile Safari will mysteriously disappear // sometimes. This works around that. function iosHacks(dom: HTMLElement) { if (dom.nodeName == "UL" || dom.nodeName == "OL") { let oldCSS = dom.style.cssText dom.style.cssText = oldCSS + "; list-style: square !important" window.getComputedStyle(dom).listStyle dom.style.cssText = oldCSS } } // Find a piece of text in an inline fragment, overlapping from-to function findTextInFragment(frag: Fragment, text: string, from: number, to: number) { for (let i = 0, pos = 0; i < frag.childCount && pos <= to;) { let child = frag.child(i++), childStart = pos pos += child.nodeSize if (!child.isText) continue let str = child.text! while (i < frag.childCount) { let next = frag.child(i++) pos += next.nodeSize if (!next.isText) break str += next.text } if (pos >= from) { if (pos >= to && str.slice(to - text.length - childStart, to - childStart) == text) return to - text.length let found = childStart < to ? str.lastIndexOf(text, to - childStart - 1) : -1 if (found >= 0 && found + text.length + childStart >= from) return childStart + found if (from == to && str.length >= (to + text.length) - childStart && str.slice(to - childStart, to - childStart + text.length) == text) return to } } return -1 } // Replace range from-to in an array of view descs with replacement // (may be null to just delete). This goes very much against the grain // of the rest of this code, which tends to create nodes with the // right shape in one go, rather than messing with them after // creation, but is necessary in the composition hack. function replaceNodes(nodes: readonly ViewDesc[], from: number, to: number, view: EditorView, replacement?: ViewDesc) { let result = [] for (let i = 0, off = 0; i < nodes.length; i++) { let child = nodes[i], start = off, end = off += child.size if (start >= to || end <= from) { result.push(child) } else { if (start < from) result.push((child as MarkViewDesc | TextViewDesc).slice(0, from - start, view)) if (replacement) { result.push(replacement) replacement = undefined } if (end > to) result.push((child as MarkViewDesc | TextViewDesc).slice(to - start, child.size, view)) } } return result } prosemirror-view/style/000077500000000000000000000000001527132543100155545ustar00rootroot00000000000000prosemirror-view/style/prosemirror.css000066400000000000000000000023331527132543100206520ustar00rootroot00000000000000.ProseMirror { position: relative; } .ProseMirror { word-wrap: break-word; white-space: pre-wrap; white-space: break-spaces; -webkit-font-variant-ligatures: none; font-variant-ligatures: none; font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ } .ProseMirror pre { white-space: pre-wrap; } .ProseMirror li { position: relative; } .ProseMirror-hideselection *::selection { background: transparent; } .ProseMirror-hideselection *::-moz-selection { background: transparent; } .ProseMirror-hideselection { caret-color: transparent; } /* See https://code.haverbeke.berlin/prosemirror/prosemirror/issues/1421#issuecomment-1759320191 */ .ProseMirror [draggable][contenteditable=false] { user-select: text } .ProseMirror-selectednode { outline: 2px solid #8cf; } /* Make sure li selections wrap around markers */ li.ProseMirror-selectednode { outline: none; } li.ProseMirror-selectednode:after { content: ""; position: absolute; left: -32px; right: -2px; top: -2px; bottom: -2px; border: 2px solid #8cf; pointer-events: none; } /* Protect against generic img rules */ img.ProseMirror-separator { display: inline !important; border: none !important; margin: 0 !important; } prosemirror-view/test/000077500000000000000000000000001527132543100153735ustar00rootroot00000000000000prosemirror-view/test/view.ts000066400000000000000000000035731527132543100167250ustar00rootroot00000000000000import {EditorView, DirectEditorProps} from "prosemirror-view" import {EditorState, Selection, TextSelection, NodeSelection, Plugin} from "prosemirror-state" import {schema} from "prosemirror-test-builder" import {Node as PMNode} from "prosemirror-model" function selFor(doc: PMNode) { let {tag} = doc as any, a = tag.a if (a != null) { let $a = doc.resolve(a) if ($a.parent.inlineContent) return new TextSelection($a, tag.b != null ? doc.resolve(tag.b) : undefined) else return new NodeSelection($a) } return Selection.atStart(doc) } let tempView: EditorView | null = null export function tempEditor(inProps: Partial & {plugins?: readonly Plugin[], doc?: PMNode} = {}) { let space = document.querySelector("#workspace") if (tempView) { tempView.destroy() tempView = null } let props: DirectEditorProps = {} as any for (let n in inProps) if (n != "plugins") (props as any)[n] = (inProps as any)[n] props.state = EditorState.create({doc: inProps.doc, schema, selection: inProps.doc && selFor(inProps.doc), plugins: inProps && inProps.plugins}) return tempView = new EditorView(space, props) } export function findTextNode(node: Node, text: string): Text | undefined { 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 = findTextNode(ch, text) if (found) return found } } } export function requireFocus(pm: EditorView) { if (!document.hasFocus()) throw new Error("The document doesn't have focus, which is needed for this test") pm.focus() return pm } export function flush(view: EditorView) { // @ts-ignore: view.domObserver is an internal API view.domObserver.flush() } prosemirror-view/test/webtest-clipboard.ts000066400000000000000000000145061527132543100213630ustar00rootroot00000000000000import ist from "ist" import {eq, doc, p, strong, blockquote, ul, ol, li, hr, br} from "prosemirror-test-builder" import {NodeSelection, TextSelection} from "prosemirror-state" import {Slice, Fragment, Schema} from "prosemirror-model" import {tempEditor} from "./view.js" import {__parseFromClipboard as parseFromClipboard} from "prosemirror-view" describe("Clipboard interface", () => { it("copies only the node for a node selection", () => { let d = doc(blockquote(p("a"), "
    ", hr()), p("b")) let view = tempEditor({doc: d}) let {dom} = view.serializeForClipboard(NodeSelection.create(d, (d as any).tag.a).content()) ist(dom.innerHTML, '

  • o

    b

  • ') ist(parseFromClipboard(view, text, dom.innerHTML, false, d.resolve(1)), d.slice((d as any).tag.a, (d as any).tag.b, true), eq) ist(parseFromClipboard(view, text, dom.innerHTML, true, d.resolve(1)), new Slice(doc(p("o"), p("b")).content, 1, 1), eq) }) it("preserves open nodes", () => { let d = doc(blockquote(blockquote(p("foo")))) let view = tempEditor({doc: d}) let slice = new Slice(Fragment.from(d.firstChild), 1, 1) let html = view.serializeForClipboard(slice).dom.innerHTML let parsed = parseFromClipboard(view, "-", html, false, d.resolve(1)) ist(parsed, slice, eq) }) it("uses clipboardTextSerializer when given", () => { let view = tempEditor({doc: doc(p("hello")), clipboardTextSerializer(_) { return "OK" }}) let {text} = view.serializeForClipboard(view.state.doc.slice(1, 6)) ist(text, "OK") }) it("can read external HTML", () => { let view = tempEditor(), $p = view.state.doc.resolve(1) ist(parseFromClipboard(view, "", "

    hello


    ", false, $p), new Slice(doc(p("hello"), hr()).content, 1, 0), eq) ist(parseFromClipboard(view, "", "

    hello

    bar", false, $p), new Slice(doc(p("hello"), p("bar")).content, 1, 1), eq) }) it("will sanely clean up top-level nodes in HTML", () => { let view = tempEditor(), $p = view.state.doc.resolve(1) ist(parseFromClipboard(view, "", "bar
    baz", false, $p), new Slice(doc(ul(li(p("foo"))), p("bar", br(), "baz")).content, 3, 1), eq) ist(parseFromClipboard(view, "", "bar

    x

    ", false, $p), new Slice(doc(ul(li(p("foo"))), p("bar", br()), p("x")).content, 3, 1), eq) ist(parseFromClipboard(view, "", "
  • foo
  • bar
  • x

    ", false, $p), new Slice(doc(ol(li(p("foo")), li(p("bar"))), p("x")).content, 3, 1), eq) }) it("only drops trailing br nodes in block parents", () => { let view = tempEditor() ist(parseFromClipboard(view, "", "

    a
    b

    ", false, view.state.doc.resolve(1)), new Slice(doc(p(strong("a"), strong(br), " b")).content, 1, 1), eq) }) it("will call transformPastedHTML", () => { let view = tempEditor({transformPastedHTML(_) { return "abc" }}) ist(parseFromClipboard(view, "", "def", false, view.state.doc.resolve(1)), new Slice(p("abc").content, 0, 0), eq) }) it("will call transformPastedText", () => { let view = tempEditor({transformPastedText(_) { return "abc" }}) ist(parseFromClipboard(view, "def", null, false, view.state.doc.resolve(1)), new Slice(doc(p("abc")).content, 1, 1), eq) }) it("allows text parsing to be overridden with clipboardTextParser", () => { let view = tempEditor({clipboardTextParser(text) { return doc(p(text.toUpperCase())).slice(1, text.length + 1) }}) ist(parseFromClipboard(view, "abc", null, false, view.state.doc.resolve(1)), new Slice(p("ABC").content, 0, 0), eq) }) it("preserves attributes", () => { let d = doc(ol({order: 3}, li(p("f
    oo")))) let view = tempEditor({doc: d}) let {dom, text} = view.serializeForClipboard(TextSelection.create(d, (d as any).tag.a, (d as any).tag.b).content()) ist(parseFromClipboard(view, text, dom.innerHTML, false, d.resolve(1)), d.slice((d as any).tag.a, (d as any).tag.b, true), eq) }) it("uses style tags included in the paste", () => { let view = tempEditor({doc: doc(p())}) ist(parseFromClipboard(view, "x", "

    bold text

    ", false, view.state.doc.resolve(1)), doc(p(strong("bold"), " text")).slice(1, 10, true), eq) }) function tableSchema() { return new Schema({ nodes: { td: {content: "text*", toDOM: () => ["td", 0], parseDOM: [{tag: "td"}]}, tr: {content: "td+", toDOM: () => ["tr", 0], parseDOM: [{tag: "tr"}]}, table: {content: "tr+", toDOM: () => ["table", ["tbody", 0]], parseDOM: [{tag: "table"}]}, doc: {content: "table+"}, text: {} } }) } it("adds necessary wrappers for parsing", () => { let s = tableSchema() let doc = s.node("doc", null, [s.node("table", null, [s.node("tr", null, [ s.node("td", null, [s.text("A")]), s.node("td", null, [s.text("B")]) ])])]) let view = tempEditor({doc}) let slice = doc.slice(3, 4, true) let html = view.serializeForClipboard(slice).dom.innerHTML ist(/ { let s = tableSchema() let doc = s.node("doc", null, [s.node("table", null, [s.node("tr", null, [s.node("td")])])]) let html = `

    123

    123

    ` ist(parseFromClipboard(tempEditor({doc}), "", html, false, doc.resolve(1)) + "", "(1,1)") }) }) prosemirror-view/test/webtest-composition.ts000066400000000000000000000255671527132543100220000ustar00rootroot00000000000000import {schema, eq, doc, p, em, code, strong} from "prosemirror-test-builder" import ist from "ist" import {Decoration, DecorationSet, __endComposition, EditorView} from "prosemirror-view" import {EditorState, Plugin} from "prosemirror-state" import {tempEditor, requireFocus, findTextNode, flush} from "./view.js" function event(pm: EditorView, type: string) { pm.dom.dispatchEvent(new CompositionEvent(type)) } function edit(node: Text, text = "", 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 hasCompositionNode(_pm: EditorView) { let {focusNode} = document.getSelection()! while (focusNode && !focusNode.pmViewDesc) focusNode = focusNode.parentNode return focusNode && focusNode.pmViewDesc!.constructor.name == "CompositionViewDesc" } function compose(pm: EditorView, start: () => Text, update: ((node: Text) => void)[], options: any = {}) { event(pm, "compositionstart") ist(pm.composing) 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 flush(pm) if (options.cancel && i == update.length - 1) { ist(!hasCompositionNode(pm)) } else { ist(node!.parentNode! && pm.dom.contains(node!.parentNode!)) ist(sel.focusNode, focusNode) ist(sel.focusOffset, focusOffset) if (options.node) ist(hasCompositionNode(pm)) } } event(pm, "compositionend") if (options.end) { options.end(node!) flush(pm) } __endComposition(pm) ist(!pm.composing) ist(!hasCompositionNode(pm)) } function wordDeco(state: EditorState) { let re = /\w+/g, deco: Decoration[] = [] state.doc.descendants((node, pos) => { if (node.isText) for (let m; m = re.exec(node.text!);) deco.push(Decoration.inline(pos + m.index, pos + m.index + m[0].length, {class: "word"})) }) return DecorationSet.create(state.doc, deco) } const wordHighlighter = new Plugin({ props: {decorations: wordDeco} }) function widgets(positions: number[], sides: number[]) { return new Plugin({ state: { init(state) { let deco = positions.map((p, i) => Decoration.widget(p, () => { let s = document.createElement("var") s.textContent = "×" return s }, {side: sides[i]})) return DecorationSet.create(state.doc!, deco) }, apply(tr, deco) { return deco.map(tr.mapping, tr.doc) } }, props: { decorations(this: Plugin, state) { return this.getState(state) } } }) } describe("EditorView composition", () => { it("supports composition in an empty block", () => { let pm = requireFocus(tempEditor({doc: doc(p("
    "))})) compose(pm, () => edit(pm.dom.firstChild!.appendChild(document.createTextNode("a"))), [ n => edit(n, "b"), n => edit(n, "c") ], {node: true}) ist(pm.state.doc, doc(p("abc")), eq) }) it("supports composition at end of block", () => { let pm = requireFocus(tempEditor({doc: doc(p("foo"))})) compose(pm, () => edit(findTextNode(pm.dom, "foo")!), [ n => edit(n, "!"), n => edit(n, "?") ]) ist(pm.state.doc, doc(p("foo!?")), eq) }) it("supports composition at end of block in a new node", () => { let pm = requireFocus(tempEditor({doc: doc(p("foo"))})) compose(pm, () => edit(pm.dom.firstChild!.appendChild(document.createTextNode("!"))), [ n => edit(n, "?") ], {node: true}) ist(pm.state.doc, doc(p("foo!?")), eq) }) it("supports composition at start of block in a new node", () => { let pm = requireFocus(tempEditor({doc: doc(p("foo"))})) compose(pm, () => { let p = pm.dom.firstChild! return edit(p.insertBefore(document.createTextNode("!"), p.firstChild)) }, [ n => edit(n, "?") ], {node: true}) ist(pm.state.doc, doc(p("!?foo")), eq) }) it("supports composition inside existing text", () => { let pm = requireFocus(tempEditor({doc: doc(p("foo"))})) compose(pm, () => edit(findTextNode(pm.dom, "foo")!), [ n => edit(n, "x", 1), n => edit(n, "y", 2), n => edit(n, "z", 3) ]) ist(pm.state.doc, doc(p("fxyzoo")), eq) }) it("can deal with Android-style newline-after-composition", () => { let pm = requireFocus(tempEditor({doc: doc(p("abcdef"))})) compose(pm, () => edit(findTextNode(pm.dom, "abcdef")!), [ n => edit(n, "x", 3), n => edit(n, "y", 4) ], {end: (n: Text) => { let line = pm.dom.appendChild(document.createElement("div")) line.textContent = "def" n.nodeValue = "abcxy" document.getSelection()!.collapse(line, 0) }}) ist(pm.state.doc, doc(p("abcxy"), p("def")), eq) }) it("handles replacement of existing words", () => { let pm = requireFocus(tempEditor({doc: doc(p("one two three"))})) compose(pm, () => edit(findTextNode(pm.dom, "one two three")!, "five", 4, 7), [ n => edit(n, "seven", 4, 8), n => edit(n, "zero", 4, 9) ]) ist(pm.state.doc, doc(p("one zero three")), eq) }) it("handles composition inside marks", () => { let pm = requireFocus(tempEditor({doc: doc(p("one ", em("two")))})) compose(pm, () => edit(findTextNode(pm.dom, "two")!, "o"), [ n => edit(n, "o"), n => edit(n, "w") ]) ist(pm.state.doc, doc(p("one ", em("twooow"))), eq) }) it("handles composition in a mark that has multiple children", () => { let pm = requireFocus(tempEditor({doc: doc(p("one ", em("two", strong(" three"))))})) compose(pm, () => edit(findTextNode(pm.dom, "two")!, "o"), [ n => edit(n, "o"), n => edit(n, "w") ]) ist(pm.state.doc, doc(p("one ", em("twooow", strong(" three")))), eq) }) it("supports composition in a cursor wrapper", () => { let pm = requireFocus(tempEditor({doc: doc(p(""))})) pm.dispatch(pm.state.tr.addStoredMark(schema.marks.em.create())) compose(pm, () => edit(pm.dom.firstChild!.appendChild(document.createTextNode("")), "a"), [ n => edit(n, "b"), n => edit(n, "c") ], {node: true}) ist(pm.state.doc, doc(p(em("abc"))), eq) }) it("handles composition in a multi-child mark with a cursor wrapper", () => { let pm = requireFocus(tempEditor({doc: doc(p("one ", em("two", strong(" three"))))})) pm.dispatch(pm.state.tr.addStoredMark(schema.marks.code.create())) let emNode = pm.dom.querySelector("em")! compose(pm, () => edit(emNode.insertBefore(document.createTextNode(""), emNode.querySelector("strong")), "o"), [ n => edit(n, "o"), n => edit(n, "w") ], {node: true}) ist(pm.state.doc, doc(p("one ", em("two", code("oow"), strong(" three")))), eq) }) it("doesn't get interrupted by changes in decorations", () => { let pm = requireFocus(tempEditor({doc: doc(p("foo ...")), plugins: [wordHighlighter]})) compose(pm, () => edit(findTextNode(pm.dom, " ...")!), [ n => edit(n, "hi", 1, 4) ]) ist(pm.state.doc, doc(p("foo hi")), eq) }) it("works inside highlighted text", () => { let pm = requireFocus(tempEditor({doc: doc(p("one two")), plugins: [wordHighlighter]})) compose(pm, () => edit(findTextNode(pm.dom, "one")!, "x"), [ n => edit(n, "y"), n => edit(n, ".") ]) ist(pm.state.doc, doc(p("onexy. two")), eq) }) it("can handle compositions spanning multiple nodes", () => { let pm = requireFocus(tempEditor({doc: doc(p("one two")), plugins: [wordHighlighter]})) compose(pm, () => edit(findTextNode(pm.dom, "two")!, "a"), [ n => edit(n, "b"), n => edit(n, "c") ], {end: (n: Text) => { n.parentNode!.previousSibling!.remove() n.parentNode!.previousSibling!.remove() return edit(n, "xyzone ", 0) }}) ist(pm.state.doc, doc(p("xyzone twoabc")), eq) }) it("doesn't overwrite widgets next to the composition", () => { let pm = requireFocus(tempEditor({doc: doc(p("")), plugins: [widgets([1, 1], [-1, 1])]})) compose(pm, () => { let p = pm.dom.firstChild! return edit(p.insertBefore(document.createTextNode("a"), p.lastChild)) }, [n => edit(n, "b", 0, 1)], {end: () => { ist(pm.dom.querySelectorAll("var").length, 2) }}) ist(pm.state.doc, doc(p("b")), eq) }) it("cancels composition when a change fully overlaps with it", () => { let pm = requireFocus(tempEditor({doc: doc(p("one"), p("two"), p("three"))})) compose(pm, () => edit(findTextNode(pm.dom, "two")!, "x"), [ () => pm.dispatch(pm.state.tr.insertText("---", 3, 13)) ], {cancel: true}) ist(pm.state.doc, doc(p("on---hree")), eq) }) it("cancels composition when a change partially overlaps with it", () => { let pm = requireFocus(tempEditor({doc: doc(p("one"), p("two"), p("three"))})) compose(pm, () => edit(findTextNode(pm.dom, "two")!, "x", 0), [ () => pm.dispatch(pm.state.tr.insertText("---", 7, 15)) ], {cancel: true}) ist(pm.state.doc, doc(p("one"), p("x---ee")), eq) }) it("cancels composition when a change happens inside of it", () => { let pm = requireFocus(tempEditor({doc: doc(p("one"), p("two"), p("three"))})) compose(pm, () => edit(findTextNode(pm.dom, "two")!, "x", 0), [ () => pm.dispatch(pm.state.tr.insertText("!", 7, 8)) ], {cancel: true}) ist(pm.state.doc, doc(p("one"), p("x!wo"), p("three")), eq) }) it("doesn't cancel composition when a change happens elsewhere", () => { let pm = requireFocus(tempEditor({doc: doc(p("one"), p("two"), p("three"))})) compose(pm, () => edit(findTextNode(pm.dom, "two")!, "x", 0), [ n => edit(n, "y", 1), () => pm.dispatch(pm.state.tr.insertText("!", 2, 3)), n => edit(n, "z", 2) ]) ist(pm.state.doc, doc(p("o!e"), p("xyztwo"), p("three")), eq) }) it("handles compositions rapidly following each other", () => { let pm = requireFocus(tempEditor({doc: doc(p("one"), p("two"))})) event(pm, "compositionstart") let one = findTextNode(pm.dom, "one")! edit(one, "!") flush(pm) event(pm, "compositionend") one.nodeValue = "one!!" let L2 = pm.dom.lastChild event(pm, "compositionstart") let two = findTextNode(pm.dom, "two")! ist(pm.dom.lastChild, L2) edit(two, ".") flush(pm) ist(document.getSelection()!.focusNode, two) ist(document.getSelection()!.focusOffset, 4) ist(pm.composing) event(pm, "compositionend") flush(pm) ist(pm.state.doc, doc(p("one!!"), p("two.")), eq) }) it("can handle cross-paragraph compositions", () => { let pm = requireFocus(tempEditor({doc: doc(p("one two"), p("three"), p("four five"))})) compose(pm, () => { let target = pm.dom.firstChild!.firstChild as Text target.nodeValue = "one A five" document.getSelection()!.collapse(target, 5) return target }, [ n => edit(n, "B", 4, 5), n => edit(n, "C", 4, 5) ]) ist(pm.state.doc, doc(p("one C five")), eq) }) }) prosemirror-view/test/webtest-decoration.ts000066400000000000000000000430251527132543100215510ustar00rootroot00000000000000import ist from "ist" import {schema, doc, p, h1, li, ul, blockquote, builders} from "prosemirror-test-builder" import {Transform, ReplaceStep, ReplaceAroundStep, StepMap, liftTarget} from "prosemirror-transform" import {Schema, Slice, NodeRange, Node, Fragment} from "prosemirror-model" import {Decoration, DecorationSet} from "prosemirror-view" let widget = document.createElement("button") type DecoSpec = Decoration | {pos: number, name?: string, side?: number} | {from: number, to: number, node?: boolean, attrs?: any, name?: string, inclusiveStart?: boolean, inclusiveEnd?: boolean} function make(d: DecoSpec) { if ((d as Decoration).type) return d as Decoration if ((d as any).pos != null) return Decoration.widget((d as {pos: number}).pos, widget, d as any) if ((d as any).node) return Decoration.node((d as any).from, (d as any).to, (d as any).attrs || {}, d) return Decoration.inline((d as any).from, (d as any).to, (d as any).attrs || {}, d as any) } function build(doc: Node, ...decorations: DecoSpec[]) { return DecorationSet.create(doc, decorations.map(make)) } function str(set: DecorationSet) { if (!set) return "[]" let s = "[" + set.local.map(d => d.from + "-" + d.to).join(", ") for (let i = 0; i < set.children.length; i += 3) s += (s.length > 1 ? ", " : "") + set.children[i] + ": " + str(set.children[i + 2] as DecorationSet) return s + "]" } function findStr(set: DecorationSet) { if (!set) return "[]" return "[" + set.find().map(d => `(${d.from},${d.to})`).join(", ") + "]" } function arrayStr(arr: readonly any[]) { return arr.map(d => d.from + "-" + d.to).join(", ") } function buildMap(doc: Node, ...decorations: (DecoSpec | ((tr: Transform) => Transform))[]) { let f = decorations.pop()! let oldSet = build(doc, ...decorations as DecoSpec[]) let tr = (f as any)(new Transform(doc)) return {set: oldSet.map(tr.mapping, tr.doc), oldSet} } function buildAdd(doc: Node, ...decorations: (DecoSpec | DecoSpec[])[]) { let toAdd = decorations.pop()! return build(doc, ...decorations as DecoSpec[]).add(doc, Array.isArray(toAdd) ? toAdd.map(make) : [make(toAdd)]) } function buildRem(doc: Node, ...decorations: (DecoSpec | DecoSpec[])[]) { let toAdd = decorations.pop()! return build(doc, ...decorations as DecoSpec[]).remove(Array.isArray(toAdd) ? toAdd.map(make) : [make(toAdd)]) } describe("DecorationSet", () => { it("builds up a matching tree", () => { let set = build(doc(p("foo"), blockquote(p("bar"))), {from: 2, to: 3}, {from: 8, to: 9}) ist(str(set), "[0: [1-2], 5: [0: [1-2]]]") }) it("does not build nodes when there are no decorations", () => { let set = build(doc(p("foo"), blockquote(p("bar"))), {from: 8, to: 9}) ist(str(set), "[5: [0: [1-2]]]") }) it("puts decorations between children in local", () => { let set = build(doc(p("a"), p("b")), {pos: 3}) ist(str(set), "[3-3]") }) it("puts decorations spanning children in local", () => { let set = build(doc(p("a"), p("b")), {from: 1, to: 5}) ist(str(set), "[1-5]") }) it("puts node decorations in the parent node", () => { let set = build(doc(p("a"), p("b")), {from: 3, to: 6, node: true}) ist(str(set), "[3-6]") }) it("drops empty inline decorations", () => { let set = build(doc(p()), {from: 1, to: 1}) ist(str(set), "[]") }) describe("find", () => { it("finds all when no arguments are given", () => { let set = build(doc(p("a"), p("b")), {from: 1, to: 2}, {pos: 3}) ist(arrayStr(set.find()), "3-3, 1-2") }) it("finds only those within the given range", () => { let set = build(doc(p("a"), p("b")), {from: 1, to: 2}, {pos: 1}, {from: 4, to: 5}) ist(arrayStr(set.find(0, 3)), "1-1, 1-2") }) it("finds decorations at the edge of the range", () => { let set = build(doc(p("a"), p("b")), {from: 1, to: 2}, {pos: 3}, {from: 4, to: 5}) ist(arrayStr(set.find(2, 3)), "3-3, 1-2") }) it("returns the correct offset for deeply nested decorations", () => { let set = build(doc(blockquote(blockquote(p("a")))), {from: 3, to: 4}) ist(arrayStr(set.find()), "3-4") }) it("can filter by predicate", () => { let set = build(doc(blockquote(blockquote(p("a")))), {from: 3, to: 4, name: "X"}, {from: 3, to: 4, name: "Y"}) ist(set.find(undefined, undefined, x => x.name == "Y").map(d => d.spec.name).join(), "Y") }) }) describe("map", () => { it("supports basic mapping", () => { let {oldSet, set} = buildMap(doc(p("foo"), p("bar")), {from: 2, to: 3}, {from: 7, to: 8}, tr => tr.replaceWith(4, 4, schema.text("!!"))) ist(str(oldSet), "[0: [1-2], 5: [1-2]]") ist(str(set), "[0: [1-2], 7: [1-2]]") }) it("drops deleted decorations", () => { let {set} = buildMap(doc(p("foobar")), {from: 2, to: 3}, tr => tr.delete(1, 4)) ist(str(set), "[]") }) it("can map node decorations", () => { let {set} = buildMap(doc(blockquote(p("a"), p("b"))), {from: 4, to: 7, node: true}, tr => tr.delete(1, 4)) ist(str(set), "[0: [0-3]]") }) it("can map inside node decorations", () => { let {set} = buildMap(doc(blockquote(p("a"), p("b"))), {from: 4, to: 7, node: true}, tr => tr.replaceWith(5, 5, schema.text("c"))) ist(str(set), "[0: [3-7]]") }) it("removes partially overwritten node decorations", () => { let {set} = buildMap(doc(p("a"), p("b")), {from: 0, to: 3, node: true}, tr => tr.delete(2, 4)) ist(str(set), "[]") }) it("removes exactly overwritten node decorations", () => { let {set} = buildMap(doc(p("a"), p("b")), {from: 0, to: 3, node: true}, tr => tr.replaceWith(0, 3, schema.nodes.horizontal_rule.create())) ist(str(set), "[]") }) it("isn't inclusive by default", () => { let {set} = buildMap(doc(p("foo")), {from: 2, to: 3}, tr => tr.replaceWith(2, 2, schema.text(".")).replaceWith(4, 4, schema.text("?"))) ist(str(set), "[0: [2-3]]") }) it("understands unclusiveLeft", () => { let {set} = buildMap(doc(p("foo")), {from: 2, to: 3, inclusiveStart: true}, tr => tr.replaceWith(2, 2, schema.text(".")).replaceWith(4, 4, schema.text("?"))) ist(str(set), "[0: [1-3]]") }) it("understands unclusiveRight", () => { let {set} = buildMap(doc(p("foo")), {from: 2, to: 3, inclusiveEnd: true}, tr => tr.replaceWith(2, 2, schema.text(".")).replaceWith(4, 4, schema.text("?"))) ist(str(set), "[0: [2-4]]") }) it("preserves subtrees not touched by mapping", () => { let {oldSet, set} = buildMap(doc(p("foo"), blockquote(p("bar"), p("baz"))), {from: 2, to: 3}, {from: 8, to: 9}, {from: 13, to: 14}, tr => tr.delete(8, 9)) ist(str(set), "[0: [1-2], 5: [4: [1-2]]]") ist(set.children[2], oldSet.children[2]) // FIXME sane accessors? ist((set as any).children[5].children[2], (oldSet as any).children[5].children[5]) }) it("rebuilds when a node is joined", () => { let {set} = buildMap(doc(p("foo"), p("bar")), {from: 2, to: 3}, {from: 7, to: 8}, tr => tr.join(5)) ist(str(set), "[0: [1-2, 4-5]]") }) it("rebuilds when a node is split", () => { let {set} = buildMap(doc(p("foobar")), {from: 2, to: 3}, {from: 5, to: 6}, tr => tr.split(4)) ist(str(set), "[0: [1-2], 5: [1-2]]") }) it("correctly rebuilds a deep structure", () => { let {oldSet, set} = buildMap(doc(blockquote(p("foo")), blockquote(blockquote(p("bar")))), {from: 3, to: 4}, {from: 11, to: 12}, tr => tr.join(7)) ist(str(oldSet), "[0: [0: [1-2]], 7: [0: [0: [1-2]]]]") ist(str(set), "[0: [0: [1-2], 5: [0: [1-2]]]]") }) it("calls onRemove when dropping decorations", () => { let d = doc(blockquote(p("hello"), p("abc"))) let set = build(d, {from: 3, to: 5, name: "a"}, {pos: 10, name: "b"}) let tr = new Transform(d).delete(2, 6), dropped: string[] = [] set.map(tr.mapping, tr.doc, {onRemove: o => dropped.push(o.name)}) ist(JSON.stringify(dropped), '["a"]') let tr2 = new Transform(d).delete(0, d.content.size), dropped2: string[] = [] set.map(tr2.mapping, tr2.doc, {onRemove: o => dropped2.push(o.name)}) ist(JSON.stringify(dropped2.sort()), '["a","b"]') }) it("respects the side option on widgets", () => { let d = doc(p("foo")) let set = build(d, {pos: 3, side: -1, name: "a"}, {pos: 3, name: "b"}) let tr = new Transform(d).replaceWith(3, 3, schema.text("ay")) let result = set.map(tr.mapping, tr.doc).find().map(d => d.from + "-" + d.spec.name).sort().join(", ") ist(result, "3-a, 5-b") }) it("doesn't doubly map decorations nested in multiple nodes", () => { let d = doc(h1("u"), blockquote(p())) let set = build(d, {pos: 5}) let tr = new Transform(d).replaceWith(0, 3, schema.node("heading", {level: 1})) ist(set.map(tr.mapping, tr.doc).find().map(d => d.from).join(), "4") }) it("rebuilds subtrees correctly at an offset", () => { let d = doc(p("foobar"), ul(li(p("abc")), li(p("b")))) let set = build(d, {pos: 18}) let tr = new Transform(d).join(16) ist(set.map(tr.mapping, tr.doc).find().map(d => d.from).join(), "16") }) it("properly maps decorations after deleted siblings", () => { let d = doc(blockquote(blockquote(blockquote(p()), blockquote(p())), blockquote(blockquote(p()), blockquote(p())))) let set = build(d, {pos: 14}) let tr = new Transform(d).delete(2, 6).delete(8, 12) ist(set.map(tr.mapping, tr.doc).find().length, 0) }) it("can map the content of nodes that moved in the same transaction", () => { let d = doc(ul(li(p("a"))), p("bc")) let set = build(d, {from: 8, to: 10}) let tr = new Transform(d).step(new ReplaceAroundStep(0, 7, 2, 5, Slice.empty, 0, true)) let mapped = set.map(tr.mapping, tr.doc).find()[0] ist(mapped.from, 4) ist(mapped.to, 6) }) it("can handle nodes moving up multiple levels", () => { let d = doc(ul(li(p()))) let set = build(d, {node: true, from: 2, to: 4}) let range = new NodeRange(d.resolve(2), d.resolve(4), 2) let tr = new Transform(d).lift(range, liftTarget(range)!) let mapped = set.map(tr.mapping, tr.doc).find() ist(mapped.length, 1) ist(mapped[0].from, 0) ist(mapped[0].to, 2) }) it("maps inline decorations through ranges with > 3 elements", () => { // We start with a doc with one inline "word" node per word, some of which // contain inline decorations const mySchema = new Schema({ nodes: schema.spec.nodes.append({ word: {inline: true, content: "text*", toDOM() { return ["w", 0] }} }), marks: schema.spec.marks }) const b = builders(mySchema); const di = b.doc(b.blockquote(b.paragraph( b.word("Aaa "), b.word("aaa "), b.word("aaaa "), b.word("aaaaaaaa "), b.word("aa "), b.word("xxxa "), b.word("aaaaaaa "), b.word("a "), b.word("aaaaaaa "), b.word("aaaaa "), b.word("xxxa "), b.word("aaaa")))) // We're going to transform it to this doc, i.e. replace word nodes with just text content const df = b.doc(b.blockquote(b.paragraph( "Aaa aaa aaaa aaaaaaaa aa xxxa aaaaaaa a aaaaaaa aaaaa xxxa aaaa"))) // We want inline decorations to be preserved, so we'll use a custom step that allows this class MyStep extends ReplaceStep { ranges: readonly number[] constructor(from: number, to: number, slice: Slice, structure: boolean, ranges: readonly number[]) { super(from, to, slice, structure) this.ranges = ranges } getMap() { return new StepMap(this.ranges) } merge(other: MyStep) { return null } } const posBeforeFirstWord = di.tag.start - 1 const ranges = [posBeforeFirstWord, 1, 0] // Remove first word's opening token di.resolve(di.tag.start).node(2).forEach((node, offset, index) => { // Remove closing & opening tokens of middle words if (index > 0) ranges.push(posBeforeFirstWord + offset - 1, 1, 0, posBeforeFirstWord + offset, 1, 0) }) ranges.push(di.tag.end, 1, 0) // Remove closing token of last word const slice = new Slice(Fragment.from(b.paragraph(di.textContent)), 0, 0) const tr = new Transform(di).step(new MyStep(di.tag.start - 2, di.tag.end + 2, slice, false, ranges)) const startSet = build(di, {from: di.tag.s1, to: di.tag.e1}, {from: di.tag.s2, to: di.tag.e2}) const expectedSet = build(df, {from: df.tag.s1, to: df.tag.e1}, {from: df.tag.s2, to: df.tag.e2}) // As a sanity check, verify that the transform produces the expected doc, // and that individually-mapped decorations give us the expected result ist(tr.doc.toString(), df.toString()) const handMappedSet = DecorationSet.create(df, startSet.find().map(deco => make({ from: tr.mapping.map(deco.from), to: tr.mapping.map(deco.to) }))) ist(findStr(handMappedSet), findStr(expectedSet)) // startSet.map should give the same results as the hand mapping, but it doesn't, // i.e. this fails: const actualSet = startSet.map(tr.mapping, tr.doc) ist(findStr(actualSet), findStr(expectedSet)) }) it("correctly offsets a deep structure", () => { // a structure like a 5 rows 2 cols table let row = blockquote(blockquote(p("1111")), blockquote(p("2222"))) let d = doc(blockquote(row, row, row, row, row)) let decorations: DecoSpec[] = [], i = 0 let tr = new Transform(d) d.descendants((node, pos) => { if (node.type.name === 'paragraph') { if (i++ % 2 === 0) decorations.push({pos: pos + 1}) else tr.replaceWith(tr.mapping.map(pos), tr.mapping.map(pos + node.nodeSize, -1), p()) } }) let oldSet = build(d, ...decorations), set = oldSet.map(tr.mapping, tr.doc) function setStr(doc: Node, set: DecorationSet) { return set.find().map(({from}) => { let node = doc.nodeAt(from) return !node ? "X" : node.text || "" }).filter(x => x).join("-") } ist(setStr(tr.doc, set), setStr(d, oldSet)) }) it("doesn't get confused by composite changes", () => { let d = doc(p("x"), p("a"), p("x"), blockquote(p("b")), p("x")) let tr = new Transform(d).delete(14, 17).delete(6, 9).delete(0, 3) let deco = DecorationSet.create(d, [Decoration.inline(d.tag.a, d.tag.b, {})]) let changed = deco.map(tr.mapping, tr.doc) ist(changed.find().map(d => d.from + "-" + d.to), "5-6") }) }) describe("add", () => { it("can add a local decoration", () => { ist(str(buildAdd(doc(p("foo"), p("bar")), {pos: 0}, {pos: 5})), "[0-0, 5-5]") }) it("can add a decoration in a new child", () => { ist(str(buildAdd(doc(p("foo"), p("bar")), {pos: 0}, {pos: 3})), "[0-0, 0: [2-2]]") }) it("can add a decoration to an existing child", () => { ist(str(buildAdd(doc(p("foo"), p("bar")), {pos: 1}, {pos: 3})), "[0: [0-0, 2-2]]") }) it("can add a decoration beyond an existing child", () => { ist(str(buildAdd(doc(blockquote(p("foo"))), {pos: 1}, {pos: 4})), "[0: [0-0, 0: [2-2]]]") }) it("can add multiple decorations", () => { ist(str(buildAdd(doc(p("foo"), p("bar")), {pos: 1}, {from: 6, to: 9}, [{from: 1, to: 4}, {pos: 7}, {pos: 8}])), "[0: [0-0, 0-3], 5: [0-3, 1-1, 2-2]]") }) }) describe("remove", () => { it("can delete a decoration", () => { let d = make({pos: 2}) ist(str(buildRem(doc(p("foo")), {pos: 1}, d, d)), "[0: [0-0]]") }) it("can delete multiple decorations", () => { let d1 = make({pos: 2}), d2 = make({from: 2, to: 8}) ist(str(buildRem(doc(p("foo"), p("bar")), {pos: 1}, {from: 6, to: 7}, d1, d2, [d1, d2])), "[0: [0-0], 5: [0-1]]") }) it("ignores decorations that don't exist", () => { ist(str(buildRem(doc(p("foo")), {pos: 5}, {pos: 2})), "[5-5]") }) it("compares by both position and type when removing", () => { let deco = DecorationSet.create(doc(p("one")), [[1, 2], [3, 4]].map(([from, to]) => Decoration.inline(from, to, {}))) ist(deco.remove([deco.find()[0]]).find().length, 1) }) }) }) describe("removeOverlap", () => { it("returns the original array when there is no overlap", () => { let decs = [make({pos: 1}), make({from: 1, to: 4}), make({from: 1, to: 4})] ist(DecorationSet.removeOverlap(decs), decs) }) it("splits a partially overlapping decoration", () => { let decs = [make({from: 1, to: 2}), make({from: 1, to: 4}), make({from: 3, to: 4})] ist(arrayStr(DecorationSet.removeOverlap(decs)), "1-2, 1-2, 2-3, 3-4, 3-4") }) it("splits a decoration that spans multiple widgets", () => { let decs = [make({from: 1, to: 5}), make({pos: 2}), make({pos: 3})] ist(arrayStr(DecorationSet.removeOverlap(decs)), "1-2, 2-2, 2-3, 3-3, 3-5") }) it("correctly splits overlapping inline decorations", () => { let decs = [make({from: 0, to: 6}), make({from: 1, to: 4}), make({from: 3, to: 5})] ist(arrayStr(DecorationSet.removeOverlap(decs)), "0-1, 1-3, 1-3, 3-4, 3-4, 3-4, 4-5, 4-5, 5-6") }) }) prosemirror-view/test/webtest-domchange.ts000066400000000000000000000414121527132543100213450ustar00rootroot00000000000000import ist from "ist" import {eq, doc, p, pre, h1, a, em, img as img_, br, strong, blockquote} from "prosemirror-test-builder" import {EditorState, TextSelection} from "prosemirror-state" import {Step} from "prosemirror-transform" import {tempEditor, findTextNode, flush} from "./view.js" const img = img_({src: "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="}) function setSel(aNode: Node, aOff: number, fNode?: Node, fOff = 0) { let r = document.createRange(), s = window.getSelection()! r.setEnd(fNode || aNode, fNode ? fOff : aOff) r.setStart(aNode, aOff) s.removeAllRanges() s.addRange(r) } describe("DOM change", () => { it("notices when text is added", () => { let view = tempEditor({doc: doc(p("hello"))}) findTextNode(view.dom, "hello")!.nodeValue = "heLllo" flush(view) ist(view.state.doc, doc(p("heLllo")), eq) }) it("notices when text is removed", () => { let view = tempEditor({doc: doc(p("hello"))}) findTextNode(view.dom, "hello")!.nodeValue = "heo" flush(view) ist(view.state.doc, doc(p("heo")), eq) }) it("handles ambiguous changes", () => { let view = tempEditor({doc: doc(p("hello"))}) findTextNode(view.dom, "hello")!.nodeValue = "helo" flush(view) ist(view.state.doc, doc(p("helo")), eq) }) it("respects stored marks", () => { let view = tempEditor({doc: doc(p("hello"))}) view.dispatch(view.state.tr.addStoredMark(view.state.schema.marks.em.create())) findTextNode(view.dom, "hello")!.nodeValue = "helloo" flush(view) ist(view.state.doc, doc(p("hello", em("o"))), eq) }) it("can add a node", () => { let view = tempEditor({doc: doc(p("hello"))}) let txt = findTextNode(view.dom, "hello")! txt.parentNode!.appendChild(document.createTextNode("!")) flush(view) ist(view.state.doc, doc(p("hello!")), eq) }) it("can remove a text node", () => { let view = tempEditor({doc: doc(p("hello"))}) let txt = findTextNode(view.dom, "hello")! txt.parentNode!.removeChild(txt) flush(view) ist(view.state.doc, doc(p()), eq) }) it("can add a paragraph", () => { let view = tempEditor({doc: doc(p("hello"))}) view.dom.insertBefore(document.createElement("p"), view.dom.firstChild) .appendChild(document.createTextNode("hey")) flush(view) ist(view.state.doc, doc(p("hey"), p("hello")), eq) }) it("supports duplicating a paragraph", () => { let view = tempEditor({doc: doc(p("hello"))}) view.dom.insertBefore(document.createElement("p"), view.dom.firstChild) .appendChild(document.createTextNode("hello")) flush(view) ist(view.state.doc, doc(p("hello"), p("hello")), eq) }) it("support inserting repeated text", () => { let view = tempEditor({doc: doc(p("hello"))}) findTextNode(view.dom, "hello")!.nodeValue = "helhello" flush(view) ist(view.state.doc, doc(p("helhello")), eq) }) it("detects an enter press", () => { let enterPressed = false let view = tempEditor({ doc: doc(blockquote(p("foo"), p(""))), handleKeyDown: (_view, event) => { if (event.keyCode == 13) return enterPressed = true } }) let bq = view.dom.querySelector("blockquote")! bq.appendChild(document.createElement("p")) flush(view) ist(enterPressed) }) it("detects an enter press creating a different block", () => { let enterPressed = false let view = tempEditor({ doc: doc(h1("")), handleKeyDown: (_view, event) => { if (event.keyCode == 13) return enterPressed = true } }) let h2 = view.dom.appendChild(document.createElement("h2")) setSel(h2, 0) flush(view) ist(enterPressed) }) it("detects a simple backspace press", () => { let backspacePressed = false let view = tempEditor({ doc: doc(p("foo"), p("bar")), handleKeyDown: (_view, event) => { if (event.keyCode == 8) return backspacePressed = true } }) view.dom.removeChild(view.dom.lastChild!) view.dom.firstChild!.textContent = "foobar" flush(view) ist(backspacePressed) }) it("detects a complex backspace press", () => { let backspacePressed = false let view = tempEditor({ doc: doc(blockquote(blockquote(p("foo")), p("", br(), "bar"))), handleKeyDown: (_view, event) => { if (event.keyCode == 8) return backspacePressed = true } }) let bq = view.dom.firstChild! bq.removeChild(bq.lastChild!) ;(bq.firstChild!.firstChild as HTMLElement).innerHTML = "foo
    bar" flush(view) ist(backspacePressed) }) it("doesn't route delete as backspace", () => { let backspacePressed = false let view = tempEditor({ doc: doc(p("foo
    "), p("bar")), handleKeyDown: (_view, event) => { if (event.keyCode == 8) return backspacePressed = true } }) view.dom.removeChild(view.dom.lastChild!) view.dom.firstChild!.textContent = "foobar" flush(view) ist(!backspacePressed) }) it("correctly adjusts the selection", () => { let view = tempEditor({doc: doc(p("abc"))}) let textNode = findTextNode(view.dom, "abc")! textNode.nodeValue = "abcd" setSel(textNode, 3) flush(view) ist(view.state.doc, doc(p("abcd")), eq) ist(view.state.selection.anchor, 4) ist(view.state.selection.head, 4) }) it("handles splitting of a textblock", () => { let view = tempEditor({doc: doc(h1("abc"), p("defg"))}) let para = view.dom.querySelector("p")! let split = para.parentNode!.appendChild(para.cloneNode()) as HTMLElement split.innerHTML = "fg" findTextNode(para, "defg")!.nodeValue = "dexy" setSel(split.firstChild!, 1) flush(view) ist(view.state.doc, doc(h1("abc"), p("dexy"), p("fg")), eq) ist(view.state.selection.anchor, 13) }) it("handles a deep split of nodes", () => { let view = tempEditor({doc: doc(blockquote(p("abcd")))}) let quote = view.dom.querySelector("blockquote")! let quote2 = view.dom.appendChild(quote.cloneNode(true)) findTextNode(quote, "abcd")!.nodeValue = "abx" let text2 = findTextNode(quote2, "abcd")! text2.nodeValue = "cd" setSel(text2.parentNode!, 0) flush(view) ist(view.state.doc, doc(blockquote(p("abx")), blockquote(p("cd"))), eq) ist(view.state.selection.anchor, 9) }) it("can delete the third instance of a character", () => { let view = tempEditor({doc: doc(p("foo xxx bar"))}) findTextNode(view.dom, "foo xxx bar")!.nodeValue = "foo xx bar" flush(view) ist(view.state.doc, doc(p("foo xx bar")), eq) }) it("can read a simple composition", () => { let view = tempEditor({doc: doc(p("hello"))}) findTextNode(view.dom, "hello")!.nodeValue = "hellox" flush(view) ist(view.state.doc, doc(p("hellox")), eq) }) it("can delete text in markup", () => { let view = tempEditor({doc: doc(p("a", em("b", img, strong("cd")), "e"))}) findTextNode(view.dom, "cd")!.nodeValue = "c" flush(view) ist(view.state.doc, doc(p("a", em("b", img, strong("c")), "e")), eq) }) it("recognizes typing inside markup", () => { let view = tempEditor({doc: doc(p("a", em("b", img, strong("cd")), "e"))}) findTextNode(view.dom, "cd")!.nodeValue = "cdxy" flush(view) ist(view.state.doc, doc(p("a", em("b", img, strong("cdxy")), "e")), eq) }) it("resolves ambiguous text input", () => { let view = tempEditor({doc: doc(p("foo"))}) view.dispatch(view.state.tr.addStoredMark(view.state.schema.marks.strong.create())) findTextNode(view.dom, "foo")!.nodeValue = "fooo" flush(view) ist(view.state.doc, doc(p("fo", strong("o"), "o")), eq) }) it("does not repaint a text node when it's typed into", () => { let view = tempEditor({doc: doc(p("foo"))}) findTextNode(view.dom, "foo")!.nodeValue = "fojo" let mutated = false, observer = new MutationObserver(() => mutated = true) observer.observe(view.dom, {subtree: true, characterData: true, childList: true}) flush(view) ist(view.state.doc, doc(p("fojo")), eq) ist(!mutated) observer.disconnect() }) it("understands text typed into an empty paragraph", () => { let view = tempEditor({doc: doc(p(""))}) view.dom.querySelector("p")!.textContent = "i" flush(view) ist(view.state.doc, doc(p("i")), eq) }) it("doesn't treat a placeholder BR as real content", () => { let view = tempEditor({doc: doc(p("i"))}) view.dom.querySelector("p")!.innerHTML = "
    " flush(view) ist(view.state.doc, doc(p()), eq) }) it("fixes text changes when input is ignored", () => { let view = tempEditor({doc: doc(p("foo")), dispatchTransaction: () => undefined}) findTextNode(view.dom, "foo")!.nodeValue = "food" flush(view) ist(view.dom.textContent, "foo") }) it("fixes structure changes when input is ignored", () => { let view = tempEditor({doc: doc(p("foo", br(), "bar")), dispatchTransaction: () => null}) let para = view.dom.querySelector("p")! para.replaceChild(document.createElement("img"), para.lastChild!) flush(view) ist(view.dom.textContent, "foobar") }) it("aborts when an incompatible state is set", () => { let view = tempEditor({doc: doc(p("abcde"))}) findTextNode(view.dom, "abcde")!.nodeValue = "xabcde" view.dispatchEvent({type: "input"} as Event) view.updateState(EditorState.create({doc: doc(p("uvw"))})) flush(view) ist(view.state.doc, doc(p("uvw")), eq) }) it("recognizes a mark change as such", () => { let view = tempEditor({doc: doc(p("one")), dispatchTransaction(tr) { ist(tr.steps.length, 1) ist(tr.steps[0].toJSON().stepType, "addMark") view.updateState(view.state.apply(tr)) }}) view.dom.querySelector("p")!.innerHTML = "one" flush(view) ist(view.state.doc, doc(p(strong("one"))), eq) }) it("preserves marks on deletion", () => { let view = tempEditor({doc: doc(p("one", em("x
    ")))}) view.dom.querySelector("em")!.innerText = "" view.dispatchEvent({type: "input"} as Event) flush(view) view.dispatch(view.state.tr.insertText("y")) ist(view.state.doc, doc(p("one", em("y"))), eq) }) it("works when a node's contentDOM is deleted", () => { let view = tempEditor({doc: doc(p("one"), pre("two"))}) view.dom.querySelector("pre")!.innerText = "" view.dispatchEvent({type: "input"} as Event) flush(view) ist(view.state.doc, doc(p("one"), pre()), eq) ist(view.state.selection.head, 6) }) it("works when a node's contentDOM is completely replaced", () => { let view = tempEditor({doc: doc(p("one"), pre("two"))}) view.dom.querySelector("pre")!.innerHTML = "q" view.dispatchEvent({type: "input"} as Event) flush(view) ist(view.state.doc, doc(p("one"), pre("q")), eq) }) it("doesn't redraw content with marks when typing in front", () => { let view = tempEditor({doc: doc(p("foo", em("bar"), strong("baz")))}) let bar = findTextNode(view.dom, "bar")!, foo = findTextNode(view.dom, "foo")! foo.nodeValue = "froo" flush(view) ist(view.state.doc, doc(p("froo", em("bar"), strong("baz"))), eq) ist(bar.parentNode && view.dom.contains(bar.parentNode)) ist(foo.parentNode && view.dom.contains(foo.parentNode)) }) it("doesn't redraw content with marks when typing inside mark", () => { let view = tempEditor({doc: doc(p("foo", em("bar"), strong("baz")))}) let bar = findTextNode(view.dom, "bar")!, foo = findTextNode(view.dom, "foo")! bar.nodeValue = "baar" flush(view) ist(view.state.doc, doc(p("foo", em("baar"), strong("baz"))), eq) ist(bar.parentNode && view.dom.contains(bar.parentNode)) ist(foo.parentNode && view.dom.contains(foo.parentNode)) }) it("maps input to coordsAtPos through pending changes", () => { let view = tempEditor({doc: doc(p("foo"))}) view.dispatchEvent({type: "input"} as Event) view.dispatch(view.state.tr.insertText("more text")) ist(view.coordsAtPos(13)) }) it("notices text added to a cursor wrapper at the start of a mark", () => { let view = tempEditor({doc: doc(p(strong(a("foo"), "bar")))}) findTextNode(view.dom, "foo")!.nodeValue = "fooxy" flush(view) ist(view.state.doc, doc(p(strong(a("foo"), "xybar"))), eq) }) it("removes cursor wrapper text when the wrapper otherwise remains valid", () => { let view = tempEditor({doc: doc(p(a(strong("foo"), "bar")))}) findTextNode(view.dom, "foo")!.nodeValue = "fooq" flush(view) ist(view.state.doc, doc(p(a(strong("fooq"), "bar"))), eq) ist(!findTextNode(view.dom, "\ufeffq")) }) it("doesn't confuse backspace with delete", () => { let steps: Step[], view = tempEditor({ doc: doc(p("aa")), dispatchTransaction(tr) { steps = tr.steps view.updateState(view.state.apply(tr)) } }) view.input.lastKeyCode = 8 view.input.lastKeyCodeTime = Date.now() findTextNode(view.dom, "aa")!.nodeValue = "a" flush(view) ist(steps!.length, 1) ist((steps![0] as any).from, 1) ist((steps![0] as any).to, 2) }) it("can disambiguate a multiple-character backspace event", () => { let steps: Step[], view = tempEditor({ doc: doc(p("foofoo")), dispatchTransaction(tr) { steps = tr.steps view.updateState(view.state.apply(tr)) } }) view.input.lastKeyCode = 8 view.input.lastKeyCodeTime = Date.now() findTextNode(view.dom, "foofoo")!.nodeValue = "foo" flush(view) ist(steps!.length, 1) ist((steps![0] as any).from, 1) ist((steps![0] as any).to, 4) }) it("doesn't confuse delete with backspace", () => { let steps: Step[], view = tempEditor({ doc: doc(p("aa")), dispatchTransaction(tr) { steps = tr.steps view.updateState(view.state.apply(tr)) } }) findTextNode(view.dom, "aa")!.nodeValue = "a" flush(view) ist(steps!.length, 1) ist((steps![0] as any).from, 2) ist((steps![0] as any).to, 3) }) it("doesn't confuse delete with backspace for multi-character deletions", () => { let steps: Step[], view = tempEditor({ doc: doc(p("one foofoo three")), dispatchTransaction(tr) { steps = tr.steps view.updateState(view.state.apply(tr)) } }) findTextNode(view.dom, "one foofoo three")!.nodeValue = "one foo three" flush(view) ist(steps!.length, 1) ist((steps![0] as any).from, 8) ist((steps![0] as any).to, 11) }) it("creates a correct step for an ambiguous selection-deletion", () => { let steps: Step[], view = tempEditor({ doc: doc(p("lalala")), dispatchTransaction(tr) { steps = tr.steps view.updateState(view.state.apply(tr)) } }) view.input.lastKeyCode = 8 view.input.lastKeyCodeTime = Date.now() findTextNode(view.dom, "lalala")!.nodeValue = "lala" flush(view) ist(steps!.length, 1) ist((steps![0] as any).from, 3) ist((steps![0] as any).to, 5) }) it("creates a step that covers the entire selection for partially-matching replacement", () => { let steps: Step[], view = tempEditor({ doc: doc(p("one two three")), dispatchTransaction(tr) { steps = tr.steps view.updateState(view.state.apply(tr)) } }) findTextNode(view.dom, "one two three")!.nodeValue = "one t three" flush(view) ist(steps!.length, 1) ist((steps![0] as any).from, 5) ist((steps![0] as any).to, 8) ist((steps![0] as any).slice.content.toString(), '<"t">') view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 7, 12))) findTextNode(view.dom, "one t three")!.nodeValue = "one t e" flush(view) ist(steps!.length, 1) ist((steps![0] as any).from, 7) ist((steps![0] as any).to, 12) ist((steps![0] as any).slice.content.toString(), '<"e">') }) it("doesn't create steps in the middle of surrogate pairs", () => { let steps: Step[] = [], view = tempEditor({ doc: doc(p("👽")), dispatchTransaction(tr) { steps = tr.steps view.updateState(view.state.apply(tr)) } }) findTextNode(view.dom, "👽")!.nodeValue = "💎👽" flush(view) ist(steps.length, 1) ist((steps[0] as any).from, 1) ist((steps[0] as any).slice.content.toString(), '<"💎">') }) it("doesn't create steps in the middle of surrogate pairs when overwriting", () => { // 𝛾 (U+1D6FE) and 𝛽 (U+1D6FD) are astral chars that share the same high // surrogate (\uD835) and differ only in the low surrogate. Overwriting one // with the other produces a non-UTF-8 step content and resulting document. let steps: Step[] = [], view = tempEditor({ doc: doc(p("𝛾")), dispatchTransaction(tr) { steps = tr.steps view.updateState(view.state.apply(tr)) } }) findTextNode(view.dom, "𝛾")!.nodeValue = "𝛽" flush(view) ist(steps.length, 1) ist((steps[0] as any).slice.content.toString(), '<"𝛽">') }) }) prosemirror-view/test/webtest-draw-decoration.ts000066400000000000000000000505201527132543100225020ustar00rootroot00000000000000import {doc, p, h1, hr, em, strong, img, blockquote, schema} from "prosemirror-test-builder" import {Plugin, TextSelection} from "prosemirror-state" import {Schema} from "prosemirror-model" import {DecorationSet, Decoration, EditorView, DecorationSource} from "prosemirror-view" import {tempEditor} from "./view.js" import ist from "ist" function make(str: string | Decoration): Decoration { if (typeof str != "string") return str let match = /^(\d+)(?:-(\d+))?-(.+)$/.exec(str)! if (match[3] == "widget") { let widget = document.createElement("button") widget.textContent = "ω" return Decoration.widget(+match[1], widget) } return Decoration.inline(+match[1], +match[2], {class: match[3]}) } function decoPlugin(decos: readonly (string | Decoration)[]) { return new Plugin({ state: { init(config) { return DecorationSet.create(config.doc!, decos.map(make)) }, apply(tr, set, state) { if (tr.docChanged) set = set.map(tr.mapping, tr.doc) let change = tr.getMeta("updateDecorations") if (change) { if (change.remove) set = set.remove(change.remove) if (change.add) set = set.add(state.doc, change.add) } return set } }, props: { decorations(this: Plugin, state) { return this.getState(state) } } }) } function updateDeco(view: EditorView, add: readonly Decoration[] | null, remove?: readonly Decoration[]) { view.dispatch(view.state.tr.setMeta("updateDecorations", {add, remove})) } describe("Decoration drawing", () => { it("draws inline decorations", () => { let view = tempEditor({doc: doc(p("foobar")), plugins: [decoPlugin(["2-5-foo"])]}) let found = view.dom.querySelector(".foo")! ist(found) ist(found.textContent, "oob") }) it("draws wrapping decorations", () => { let view = tempEditor({doc: doc(p("foo")), plugins: [decoPlugin([Decoration.inline(1, 5, {nodeName: "i"})])]}) let found = view.dom.querySelector("i") ist(found && found.innerHTML, "foo") }) it("draws node decorations", () => { let view = tempEditor({doc: doc(p("foo"), p("bar")), plugins: [decoPlugin([Decoration.node(5, 10, {class: "cls"})])]}) let found = view.dom.querySelectorAll(".cls") ist(found.length, 1) ist(found[0].nodeName, "P") ist(found[0].previousSibling!.nodeName, "P") }) it("can update multi-level wrapping decorations", () => { let d2 = Decoration.inline(1, 5, {nodeName: "i", class: "b"}) let view = tempEditor({doc: doc(p("hello")), plugins: [decoPlugin([Decoration.inline(1, 5, {nodeName: "i", class: "a"}), d2])]}) ist(view.dom.querySelectorAll("i").length, 2) updateDeco(view, [Decoration.inline(1, 5, {nodeName: "i", class: "c"})], [d2]) let iNodes = view.dom.querySelectorAll("i") ist(iNodes.length, 2) ist(Array.prototype.map.call(iNodes, n => n.className).sort().join(), "a,c") }) it("draws overlapping inline decorations", () => { let view = tempEditor({doc: doc(p("abcdef")), plugins: [decoPlugin(["3-5-foo", "4-6-bar", "1-7-baz"])]}) let baz = view.dom.querySelectorAll(".baz") as any as HTMLElement[] ist(baz.length, 5) ist(Array.prototype.map.call(baz, x => x.textContent).join("-"), "ab-c-d-e-f") function classes(n: HTMLElement) { return n.className.split(" ").sort().join(" ") } ist(classes(baz[1]), "baz foo") ist(classes(baz[2]), "bar baz foo") ist(classes(baz[3]), "bar baz") }) it("draws multiple widgets", () => { let view = tempEditor({doc: doc(p("foobar")), plugins: [decoPlugin(["1-widget", "4-widget", "7-widget"])]}) let found = view.dom.querySelectorAll("button") as any as HTMLElement[] ist(found.length, 3) ist(found[0].nextSibling!.textContent, "foo") ist(found[1].nextSibling!.textContent, "bar") ist(found[2].previousSibling!.textContent, "bar") }) it("orders widgets by their side option", () => { let view = tempEditor({doc: doc(p("foobar")), plugins: [decoPlugin([Decoration.widget(4, document.createTextNode("B")), Decoration.widget(4, document.createTextNode("A"), {side: -100}), Decoration.widget(4, document.createTextNode("C"), {side: 2})])]}) ist(view.dom.textContent, "fooABCbar") }) it("draws a widget in an empty node", () => { let view = tempEditor({doc: doc(p()), plugins: [decoPlugin(["1-widget"])]}) ist(view.dom.querySelectorAll("button").length, 1) }) it("draws widgets on node boundaries", () => { let view = tempEditor({doc: doc(p("foo", em("bar"))), plugins: [decoPlugin(["4-widget"])]}) ist(view.dom.querySelectorAll("button").length, 1) }) it("draws decorations from multiple plugins", () => { let view = tempEditor({doc: doc(p("foo", em("bar"))), plugins: [decoPlugin(["2-widget"]), decoPlugin(["6-widget"])]}) ist(view.dom.querySelectorAll("button").length, 2) }) it("calls widget destroy methods", () => { let destroyed = false let view = tempEditor({ doc: doc(p("abc")), plugins: [decoPlugin([Decoration.widget(2, document.createElement("BUTTON"), { destroy: (node) => { destroyed = true ist((node as HTMLElement).tagName, "BUTTON") } })])] }) view.dispatch(view.state.tr.delete(1, 4)) ist(destroyed) }) it("draws inline decorations spanning multiple parents", () => { let view = tempEditor({doc: doc(p("long first ", em("p"), "aragraph"), p("two")), plugins: [decoPlugin(["7-25-foo"])]}) let foos = view.dom.querySelectorAll(".foo") ist(foos.length, 4) ist(foos[0].textContent, "irst ") ist(foos[1].textContent, "p") ist(foos[2].textContent, "aragraph") ist(foos[3].textContent, "tw") }) it("draws inline decorations across empty paragraphs", () => { let view = tempEditor({doc: doc(p("first"), p(), p("second")), plugins: [decoPlugin(["3-12-foo"])]}) let foos = view.dom.querySelectorAll(".foo") ist(foos.length, 2) ist(foos[0].textContent, "rst") ist(foos[1].textContent, "se") }) it("can handle inline decorations ending at the start or end of a node", () => { let view = tempEditor({doc: doc(p(), p()), plugins: [decoPlugin(["1-3-foo"])]}) ist(!view.dom.querySelector(".foo")) }) it("can draw decorations with multiple classes", () => { let view = tempEditor({doc: doc(p("foo")), plugins: [decoPlugin(["1-4-foo bar"])]}) ist(view.dom.querySelectorAll(".foo").length, 1) ist(view.dom.querySelectorAll(".bar").length, 1) }) it("supports overlapping inline decorations", () => { let view = tempEditor({doc: doc(p("foobar")), plugins: [decoPlugin(["1-3-foo", "2-5-bar"])]}) let foos = view.dom.querySelectorAll(".foo") let bars = view.dom.querySelectorAll(".bar") ist(foos.length, 2) ist(bars.length, 2) ist(foos[0].textContent, "f") ist(foos[1].textContent, "o") ist(bars[0].textContent, "o") ist(bars[1].textContent, "ob") }) it("doesn't redraw when irrelevant decorations change", () => { let view = tempEditor({doc: doc(p("foo"), p("baz")), plugins: [decoPlugin(["7-8-foo"])]}) let para2 = view.dom.lastChild updateDeco(view, [make("2-3-bar")]) ist(view.dom.lastChild, para2) ist(view.dom.querySelector(".bar")) }) it("doesn't redraw when irrelevant content changes", () => { let view = tempEditor({doc: doc(p("foo"), p("baz")), plugins: [decoPlugin(["7-8-foo"])]}) let para2 = view.dom.lastChild view.dispatch(view.state.tr.delete(2, 3)) view.dispatch(view.state.tr.delete(2, 3)) ist(view.dom.lastChild, para2) }) it("can add a widget on a node boundary", () => { let view = tempEditor({doc: doc(p("foo", em("bar"))), plugins: [decoPlugin([])]}) updateDeco(view, [make("4-widget")]) ist(view.dom.querySelectorAll("button").length, 1) }) it("can remove a widget on a node boundary", () => { let dec = make("4-widget") let view = tempEditor({doc: doc(p("foo", em("bar"))), plugins: [decoPlugin([dec])]}) updateDeco(view, null, [dec]) ist(view.dom.querySelector("button"), null) }) it("can remove the class from a text node", () => { let dec = make("1-4-foo") let view = tempEditor({doc: doc(p("abc")), plugins: [decoPlugin([dec])]}) ist(view.dom.querySelector(".foo")) updateDeco(view, null, [dec]) ist(view.dom.querySelector(".foo"), null) }) it("can remove the class from part of a text node", () => { let dec = make("2-4-foo") let view = tempEditor({doc: doc(p("abcd")), plugins: [decoPlugin([dec])]}) ist(view.dom.querySelector(".foo")) updateDeco(view, null, [dec]) ist(view.dom.querySelector(".foo"), null) ist((view.dom.firstChild as HTMLElement).innerHTML, "abcd") }) it("can remove the class for part of a text node", () => { let dec = make("2-4-foo") let view = tempEditor({doc: doc(p("abcd")), plugins: [decoPlugin([dec])]}) ist(view.dom.querySelector(".foo")) updateDeco(view, [make("2-4-bar")], [dec]) ist(view.dom.querySelector(".foo"), null) ist(view.dom.querySelector(".bar")) }) it("draws a widget added in the middle of a text node", () => { let view = tempEditor({doc: doc(p("foo")), plugins: [decoPlugin([])]}) updateDeco(view, [make("3-widget")]) ist((view.dom.firstChild as HTMLElement).textContent, "foωo") }) it("can update a text node around a widget", () => { let view = tempEditor({doc: doc(p("bar")), plugins: [decoPlugin(["3-widget"])]}) view.dispatch(view.state.tr.delete(1, 2)) ist(view.dom.querySelectorAll("button").length, 1) ist((view.dom.firstChild as HTMLElement).textContent, "aωr") }) it("can update a text node with an inline decoration", () => { let view = tempEditor({doc: doc(p("bar")), plugins: [decoPlugin(["1-3-foo"])]}) view.dispatch(view.state.tr.delete(1, 2)) let foo = view.dom.querySelector(".foo") as HTMLElement ist(foo) ist(foo.textContent, "a") ist(foo.nextSibling!.textContent, "r") }) it("correctly redraws a partially decorated node when a widget is added", () => { let view = tempEditor({doc: doc(p("one", em("two"))), plugins: [decoPlugin(["1-6-foo"])]}) updateDeco(view, [make("6-widget")]) let foos = view.dom.querySelectorAll(".foo") ist(foos.length, 2) ist(foos[0].textContent, "one") ist(foos[1].textContent, "tw") }) it("correctly redraws when skipping split text node", () => { let view = tempEditor({doc: doc(p("foo")), plugins: [decoPlugin(["3-widget", "3-4-foo"])]}) updateDeco(view, [make("4-widget")]) ist(view.dom.querySelectorAll("button").length, 2) }) it("drops removed node decorations from the view", () => { let deco = Decoration.node(1, 6, {class: "cls"}) let view = tempEditor({doc: doc(blockquote(p("foo"), p("bar"))), plugins: [decoPlugin([deco])]}) updateDeco(view, null, [deco]) ist(!view.dom.querySelector(".cls")) }) it("can update a node's attributes without replacing the node", () => { let deco = Decoration.node(0, 5, {title: "title", class: "foo"}) let view = tempEditor({doc: doc(p("foo")), plugins: [decoPlugin([deco])]}) let para = view.dom.querySelector("p") as HTMLElement updateDeco(view, [Decoration.node(0, 5, {class: "foo bar"})], [deco]) ist(view.dom.querySelector("p"), para) ist(para.className, "foo bar") ist(!para.title) }) it("can add and remove CSS custom properties from a node", () => { let deco = Decoration.node(0, 5, {style: '--my-custom-property:36px'}) let view = tempEditor({doc: doc(p("foo")), plugins: [decoPlugin([deco])]}) ist(view.dom.querySelector("p")!.style.getPropertyValue('--my-custom-property'), "36px") updateDeco(view, null, [deco]) ist(view.dom.querySelector("p")!.style.getPropertyValue('--my-custom-property'), "") }) it("updates decorated nodes even if a widget is added before them", () => { let view = tempEditor({doc: doc(p("a"), p("b")), plugins: [decoPlugin([])]}) let lastP = view.dom.querySelectorAll("p")[1] updateDeco(view, [make("3-widget"), Decoration.node(3, 6, {style: "color: red"})]) ist(lastP.style.color, "red") }) it("doesn't redraw nodes when a widget before them is replaced", () => { let w0 = make("3-widget") let view = tempEditor({doc: doc(h1("a"), p("b")), plugins: [decoPlugin([w0])]}) let initialP = view.dom.querySelector("p") view.dispatch(view.state.tr.setMeta("updateDecorations", {add: [make("3-widget")], remove: [w0]}) .insertText("c", 5)) ist(view.dom.querySelector("p"), initialP) }) it("can add and remove inline style", () => { let deco = Decoration.inline(1, 6, {style: "color: rgba(0,10,200,.4); text-decoration: underline"}) let view = tempEditor({doc: doc(p("al", img(), "lo")), plugins: [decoPlugin([deco])]}) ist(/rgba/.test(view.dom.querySelector("img")!.style.color)) ist((view.dom.querySelector("img")!.previousSibling as HTMLElement).style.textDecoration, "underline") updateDeco(view, null, [deco]) ist(view.dom.querySelector("img")!.style.color, "") ist(view.dom.querySelector("img")!.style.textDecoration, "") }) it("passes decorations to a node view", () => { let current = "" let view = tempEditor({ doc: doc(p("foo"), hr()), plugins: [decoPlugin([])], nodeViews: {horizontal_rule: () => ({ dom: document.createElement("hr"), update(_, decos) { current = decos.map(d => d.spec.name).join() return false } })} }) let a = Decoration.node(5, 6, {}, {name: "a"}) updateDeco(view, [a], []) ist(current, "a") updateDeco(view, [Decoration.node(5, 6, {}, {name: "b"}), Decoration.node(5, 6, {}, {name: "c"})], [a]) ist(current, "b,c") }) it("draws the specified marks around a widget", () => { let view = tempEditor({ doc: doc(p("foobar")), plugins: [decoPlugin([Decoration.widget(4, document.createElement("img"), {marks: [schema.mark("em")]})])] }) ist(view.dom.querySelector("em img")) }) it("draws widgets inside the marks for their side", () => { let view = tempEditor({ doc: doc(p(em("foo"), strong("bar"))), plugins: [decoPlugin([Decoration.widget(4, document.createElement("img"), {side: -1})]), decoPlugin([Decoration.widget(4, document.createElement("br"))]), decoPlugin([Decoration.widget(7, document.createElement("span"), {side: 1})])] }) ist(view.dom.querySelector("em img")) ist(!view.dom.querySelector("strong img")) ist(view.dom.querySelector("strong br")) ist(!view.dom.querySelector("em br")) ist(!view.dom.querySelector("strong span")) }) it("draws decorations inside node views", () => { let view = tempEditor({ doc: doc(p("foo")), nodeViews: {paragraph() { let p = document.createElement("p"); return {dom: p, contentDOM: p} }}, plugins: [decoPlugin([Decoration.widget(2, document.createElement("img"))])] }) ist(view.dom.querySelector("img")) }) it("can delay widget drawing to render time", () => { let view = tempEditor({ doc: doc(p("hi")), decorations(state) { return DecorationSet.create(state.doc, [Decoration.widget(3, view => { ist(view.state, state) let elt = document.createElement("span") elt.textContent = "!" return elt })]) } }) ist(view.dom.textContent, "hi!") }) it("supports widgets querying their own position", () => { let get: () => number | undefined tempEditor({ doc: doc(p("hi")), decorations(state) { return DecorationSet.create(state.doc, [Decoration.widget(3, (_view, getPos) => { ist(getPos(), 3) get = getPos return document.createElement("button") })]) } }) ist(get!(), 3) }) it("doesn't redraw widgets with matching keys", () => { let mkButton = () => document.createElement("button") let view = tempEditor({ doc: doc(p("hi")), decorations(state) { return DecorationSet.create(state.doc, [Decoration.widget(2, mkButton, {key: "myButton"})]) } }) let widgetDOM = view.dom.querySelector("button") view.dispatch(view.state.tr.insertText("!", 2, 2)) ist(view.dom.querySelector("button"), widgetDOM) }) it("doesn't redraw widgets with identical specs", () => { let toDOM = () => document.createElement("button") let view = tempEditor({ doc: doc(p("hi")), decorations(state) { return DecorationSet.create(state.doc, [Decoration.widget(2, toDOM, {side: 1})]) } }) let widgetDOM = view.dom.querySelector("button") view.dispatch(view.state.tr.insertText("!", 2, 2)) ist(view.dom.querySelector("button"), widgetDOM) }) it("doesn't get confused by split text nodes", () => { let view = tempEditor({doc: doc(p("abab")), decorations(state) { return state.selection.from <= 1 ? null : DecorationSet.create(view.state.doc, [Decoration.inline(1, 2, {class: "foo"}), Decoration.inline(3, 4, {class: "foo"})]) }}) view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 5))) ist(view.dom.textContent, "abab") }) function thingSchema() { return new Schema({ nodes: { doc: {content: "(text | thing)*"}, text: {}, thing: {inline: true, content: "text*", toDOM: () => ["strong", 0], parseDOM: [{tag: "strong"}]} } }) } it("only draws inline decorations on the innermost level", () => { let s = thingSchema() let view = tempEditor({ doc: s.node("doc", null, [s.text("abc"), s.node("thing", null, [s.text("def")]), s.text("ghi")]), decorations: s => DecorationSet.create(s.doc, [Decoration.inline(1, 10, {class: "dec"})]) }) let styled = view.dom.querySelectorAll(".dec") ist(styled.length, 3) ist(Array.prototype.map.call(styled, n => n.textContent).join(), "bc,def,gh") ist(styled[1].parentNode!.nodeName, "STRONG") }) it("can handle inline decorations ending on inline node boundaries", () => { let s = thingSchema() let view = tempEditor({ doc: s.node("doc", null, [s.node("thing", null, s.text("abc")), s.text("def")]), decorations: s => DecorationSet.create(s.doc, [Decoration.inline(3, 5, {class: "c"})]) }) ist(view.dom.querySelectorAll(".c").length, 1) }) it("can handle nodeName decoration overlapping with classes", () => { let view = tempEditor({ doc: doc(p("one two three")), plugins: [decoPlugin([Decoration.inline(2, 13, {class: "foo"}), Decoration.inline(5, 8, {nodeName: "em"})])] }) ist((view.dom.firstChild as HTMLElement).innerHTML, 'one two three') }) it("can handle combining decorations from parent editors in child editors", () => { let decosFromFirstEditor: DecorationSource | undefined; let view = tempEditor({ doc: doc(p("one two three")), plugins: [ decoPlugin([Decoration.inline(2, 13, {class: "foo"})]), decoPlugin([Decoration.inline(2, 13, {class: "bar"})]), ], nodeViews: { paragraph: (_node, _view, _getPos, _decos, innerDecos) => { decosFromFirstEditor = innerDecos return { dom: document.createElement('p') } } } }); view = tempEditor({ doc: doc(p("one two three")), plugins: [ decoPlugin([Decoration.inline(1, 12, {class: "baz"})]), ], decorations: () => decosFromFirstEditor }); ist(view.dom.querySelectorAll(".foo").length, 1) ist(view.dom.querySelectorAll(".bar").length, 1) ist(view.dom.querySelectorAll(".baz").length, 1) }) }) prosemirror-view/test/webtest-draw.ts000066400000000000000000000153231527132543100203570ustar00rootroot00000000000000import {doc, strong, pre, h1, p, hr, schema} from "prosemirror-test-builder" import {Plugin} from "prosemirror-state" import {Schema} from "prosemirror-model" import ist from "ist" import {tempEditor} from "./view.js" describe("EditorView draw", () => { it("updates the DOM", () => { let view = tempEditor({doc: doc(p("foo"))}) view.dispatch(view.state.tr.insertText("bar")) ist(view.dom.textContent, "barfoo") }) it("doesn't redraw nodes after changes", () => { let view = tempEditor({doc: doc(h1("foo"), p("bar"))}) let oldP = view.dom.querySelector("p") view.dispatch(view.state.tr.insertText("!")) ist(view.dom.querySelector("p"), oldP) }) it("doesn't redraw nodes before changes", () => { let view = tempEditor({doc: doc(p("foo"), h1("bar"))}) let oldP = view.dom.querySelector("p") view.dispatch(view.state.tr.insertText("!", 2)) ist(view.dom.querySelector("p"), oldP) }) it("doesn't redraw nodes between changes", () => { let view = tempEditor({doc: doc(p("foo"), h1("bar"), pre("baz"))}) let oldP = view.dom.querySelector("p") let oldPre = view.dom.querySelector("pre") view.dispatch(view.state.tr.insertText("!", 2)) ist(view.dom.querySelector("p"), oldP) ist(view.dom.querySelector("pre"), oldPre) }) it("doesn't redraw siblings of a split node", () => { let view = tempEditor({doc: doc(p("foo"), h1("bar"), pre("baz"))}) let oldP = view.dom.querySelector("p") let oldPre = view.dom.querySelector("pre") view.dispatch(view.state.tr.split(8)) ist(view.dom.querySelector("p"), oldP) ist(view.dom.querySelector("pre"), oldPre) }) it("doesn't redraw siblings of a joined node", () => { let view = tempEditor({doc: doc(p("foo"), h1("bar"), h1("x"), pre("baz"))}) let oldP = view.dom.querySelector("p") let oldPre = view.dom.querySelector("pre") view.dispatch(view.state.tr.join(10)) ist(view.dom.querySelector("p"), oldP) ist(view.dom.querySelector("pre"), oldPre) }) it("doesn't redraw after a big deletion", () => { let view = tempEditor({doc: doc(p(), p(), p(), p(), p(), p(), p(), p(), h1("!"), p(), p())}) let oldH = view.dom.querySelector("h1") view.dispatch(view.state.tr.delete(2, 14)) ist(view.dom.querySelector("h1"), oldH) }) it("adds classes from the attributes prop", () => { let view = tempEditor({doc: doc(p()), attributes: {class: "foo bar"}}) ist(view.dom.classList.contains("foo")) ist(view.dom.classList.contains("bar")) ist(view.dom.classList.contains("ProseMirror")) view.update({state: view.state, attributes: {class: "baz"}}) ist(!view.dom.classList.contains("foo")) ist(view.dom.classList.contains("baz")) }) it("adds style from the attributes prop", () => { let view = tempEditor({doc: doc(p()), attributes: {style: "border: 1px solid red;"}, plugins: [new Plugin({props: { attributes: {style: "background: red;"}}}), new Plugin({props: { attributes: {style: "color: red;"}}})]}) ist(view.dom.style.border, "1px solid red") ist(view.dom.style.backgroundColor, "red") ist(view.dom.style.color, "red") }) it("can set other attributes", () => { let view = tempEditor({doc: doc(p()), attributes: {spellcheck: "false", "aria-label": "hello"}}) ist(view.dom.spellcheck, false) ist(view.dom.getAttribute("aria-label"), "hello") view.update({state: view.state, attributes: {style: "background-color: yellow"}}) ist(view.dom.hasAttribute("aria-label"), false) ist(view.dom.style.backgroundColor, "yellow") }) it("can't set the contenteditable attribute", () => { let view = tempEditor({doc: doc(p()), attributes: {contenteditable: "false"}}) ist(view.dom.contentEditable, "true") }) it("understands the editable prop", () => { let view = tempEditor({doc: doc(p()), editable: () => false}) ist(view.dom.contentEditable, "false") view.update({state: view.state}) ist(view.dom.contentEditable, "true") }) it("doesn't redraw following paragraphs when a paragraph is split", () => { let view = tempEditor({doc: doc(p("abcde"), p("fg"))}) let lastPara = view.dom.lastChild view.dispatch(view.state.tr.split(3)) ist(view.dom.lastChild, lastPara) }) it("doesn't greedily match nodes that have another match", () => { let view = tempEditor({doc: doc(p("a"), p("b"), p())}) let secondPara = view.dom.querySelectorAll("p")[1] view.dispatch(view.state.tr.split(2)) ist(view.dom.querySelectorAll("p")[2], secondPara) }) it("creates and destroys plugin views", () => { let events: string[] = [] class PluginView { update() { events.push("update") } destroy() { events.push("destroy") } } let plugin = new Plugin({ view() { events.push("create"); return new PluginView } }) let view = tempEditor({plugins: [plugin]}) view.dispatch(view.state.tr.insertText("u")) view.destroy() ist(events.join(" "), "create update destroy") }) it("redraws changed node views", () => { let view = tempEditor({doc: doc(p("foo"), hr())}) ist(view.dom.querySelector("hr")) view.setProps({nodeViews: {horizontal_rule: () => { return {dom: document.createElement("var")} }}}) ist(!view.dom.querySelector("hr")) ist(view.dom.querySelector("var")) }) it("doesn't get confused by merged nodes", () => { let view = tempEditor({doc: doc(p(strong("one"), " two ", strong("three")))}) view.dispatch(view.state.tr.removeMark(1, 4, schema.marks.strong)) ist(view.dom.querySelectorAll("strong").length, 1) }) it("doesn't redraw too much when marks are present", () => { let s = new Schema({ nodes: { doc: {content: "paragraph+", marks: "m"}, text: {group: "inline"}, paragraph: schema.spec.nodes.get("paragraph")! }, marks: { m: { toDOM: () => ["div", {class: "m"}, 0], parseDOM: [{tag: "div.m"}] } } }) let paragraphs = [] for (let i = 1; i <= 10; i++) paragraphs.push(s.node("paragraph", null, [s.text("para " + i)], [s.mark("m")])) let view = tempEditor({ doc: s.node("doc", null, paragraphs), }) let initialChildren = Array.from(view.dom.querySelectorAll("p")) let newParagraphs = [] for (let i = -6; i < 0; i++) newParagraphs.push(s.node("paragraph", null, [s.text("para " + i)], [s.mark("m")])) view.dispatch(view.state.tr.replaceWith(0, 8, newParagraphs)) let currentChildren = Array.from(view.dom.querySelectorAll("p")), sameAtEnd = 0 while (sameAtEnd < currentChildren.length && sameAtEnd < initialChildren.length && currentChildren[currentChildren.length - sameAtEnd - 1] == initialChildren[initialChildren.length - sameAtEnd - 1]) sameAtEnd++ ist(sameAtEnd, 9) }) }) prosemirror-view/test/webtest-endOfTextblock.ts000066400000000000000000000112231527132543100223300ustar00rootroot00000000000000import {schema, doc, p, a} from "prosemirror-test-builder" import ist from "ist" import {Decoration, DecorationSet} from "prosemirror-view" import {TextSelection} from "prosemirror-state" import {tempEditor} from "./view.js" describe("EditorView.endOfTextblock", () => { it("works at the left side of a textblock", () => { let view = tempEditor({doc: doc(p("a"), p("bcd"))}) ist(view.endOfTextblock("left")) ist(view.endOfTextblock("backward")) ist(!view.endOfTextblock("right")) ist(!view.endOfTextblock("forward")) }) it("works at the right side of a textblock", () => { let view = tempEditor({doc: doc(p("abc"), p("d"))}) ist(!view.endOfTextblock("left")) ist(!view.endOfTextblock("backward")) ist(view.endOfTextblock("right")) ist(view.endOfTextblock("forward")) }) it("works in the middle of a textblock", () => { let view = tempEditor({doc: doc(p("ab"))}) ist(!view.endOfTextblock("left")) ist(!view.endOfTextblock("backward")) ist(!view.endOfTextblock("right")) ist(!view.endOfTextblock("forward")) }) it("works at the start of the document", () => { let view = tempEditor({doc: doc(p("bcd"))}) ist(view.endOfTextblock("left")) ist(view.endOfTextblock("backward")) ist(!view.endOfTextblock("right")) ist(!view.endOfTextblock("forward")) }) it("works at the end of the document", () => { let view = tempEditor({doc: doc(p("abc"))}) ist(!view.endOfTextblock("left")) ist(!view.endOfTextblock("backward")) ist(view.endOfTextblock("right")) ist(view.endOfTextblock("forward")) }) it("works for vertical motion in a one-line block", () => { let view = tempEditor({doc: doc(p("abc"))}) ist(view.endOfTextblock("up")) ist(view.endOfTextblock("down")) }) it("works for vertical motion at the end of a wrapped block", () => { let view = tempEditor({doc: doc(p(new Array(100).join("foo ") + "foo"))}) ist(!view.endOfTextblock("up")) ist(view.endOfTextblock("down")) }) it("works for vertical motion at the start of a wrapped block", () => { let view = tempEditor({doc: doc(p("foo " + new Array(100).join("foo ")))}) ist(view.endOfTextblock("up")) ist(!view.endOfTextblock("down")) }) it("works for virtual motion when in a mark", () => { let view = tempEditor({doc: doc(p(a("foo "), new Array(50).join("foo "), a("foo "), new Array(50).join("foo "), a("foo")))}) ist(view.endOfTextblock("up")) ist(!view.endOfTextblock("down")) view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, (view.state.doc as any).tag.b))) ist(!view.endOfTextblock("up")) ist(!view.endOfTextblock("down")) view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, (view.state.doc as any).tag.c))) ist(!view.endOfTextblock("up")) ist(view.endOfTextblock("down")) }) // Bidi functionality only works when the browser has Selection.modify if (!(getSelection() as any).modify) return it("works at the start of an RTL block", () => { let view = tempEditor({doc: doc(p("مرآة"))}) view.dom.style.direction = "rtl" ist(!view.endOfTextblock("left")) ist(view.endOfTextblock("backward")) ist(view.endOfTextblock("right")) ist(!view.endOfTextblock("forward")) }) it("works at the end of an RTL block", () => { let view = tempEditor({doc: doc(p("مرآة"))}) view.dom.style.direction = "rtl" ist(view.endOfTextblock("left")) ist(!view.endOfTextblock("backward")) ist(!view.endOfTextblock("right")) ist(view.endOfTextblock("forward")) }) it("works inside an RTL block", () => { let view = tempEditor({doc: doc(p("مرآة"))}) view.dom.style.direction = "rtl" ist(!view.endOfTextblock("left")) ist(!view.endOfTextblock("backward")) ist(!view.endOfTextblock("right")) ist(!view.endOfTextblock("forward")) }) it("works in a bidirectional block", () => { let view = tempEditor({doc: doc(p("proseمرآة"))}) ist(!view.endOfTextblock("left")) ist(!view.endOfTextblock("backward")) ist(view.endOfTextblock("forward")) }) it("works in a cursor wrapper", () => { let view = tempEditor({doc: doc(p("foo"))}) view.dispatch(view.state.tr.setStoredMarks([schema.marks.em.create()])) ist(!view.endOfTextblock("backward")) }) it("works after a widget", () => { let d = doc(p("foo")), w = document.createElement("span") w.textContent = "!" let view = tempEditor({doc: d, decorations() { return DecorationSet.create(d, [Decoration.widget(3, w)]) }}) ist(!view.endOfTextblock("backward")) }) }) prosemirror-view/test/webtest-markview.ts000066400000000000000000000073261527132543100212530ustar00rootroot00000000000000import ist from "ist" import { a, doc, p, strong } from "prosemirror-test-builder" import { ViewMutationRecord } from "prosemirror-view" import { tempEditor, flush } from "./view.js" describe("markViews prop", () => { it("can replace a mark's representation", () => { let view = tempEditor({doc: doc(p("foo", strong("bar"))), markViews: {strong() { return {dom: document.createElement("var")}}}}) ist(view.dom.querySelector("var")) }) it("can provide a contentDOM property", () => { let view = tempEditor({ doc: doc(p(strong("foo"))), markViews: {strong() { let dom = document.createElement("var") let contentDOM = document.createElement("span") dom.appendChild(contentDOM) return {dom, contentDOM} }} }) let span = view.dom.querySelector("span")! view.dispatch(view.state.tr.insertText("a", 2)) ist(view.dom.querySelector("span"), span) ist(span.textContent, "faoo") }) it("has its ignoreMutation method called", async () => { let mutation: ViewMutationRecord | undefined let view = tempEditor({ doc: doc(p("foo", strong("bar"))), markViews: {strong() { return { dom: document.createElement("var"), ignoreMutation: (m) => { mutation = m return true } } }} }) ist(!mutation) view.dom.querySelector("var")!.classList.add("foo") flush(view) ist(mutation) ist((mutation!.target as HTMLElement).tagName, "VAR") }) it("has its destroy method called", () => { let destroyed = false let view = tempEditor({ doc: doc(p(strong("foo"))), markViews: {strong() { let dom = document.createElement("var") return {dom, destroy: () => destroyed = true} }} }) ist(view.dom.textContent, "foo") ist(!destroyed) view.dispatch(view.state.tr.delete(1, 2)) ist(view.dom.textContent, "oo") ist(!destroyed) view.dispatch(view.state.tr.delete(1, 3)) ist(view.dom.textContent, "") ist(destroyed) }) it("reuses the DOM and calls update when only a mark attr changes", () => { let updates: string[] = [] let view = tempEditor({ doc: doc(p(a("foo"))), markViews: {link(mark) { let dom = document.createElement("span") dom.setAttribute("data-href", mark.attrs.href) return { dom, update: (next) => { updates.push(next.attrs.href) dom.setAttribute("data-href", next.attrs.href) return true } } }} }) let span = view.dom.querySelector("span")! let link = view.state.schema.marks.link view.dispatch(view.state.tr.removeMark(1, 4, link).addMark(1, 4, link.create({ href: "bar" }))) // Ensure that the mark view was not rebuilt ist(view.dom.querySelector("span") === span) // Ensure the DOM was updated ist(span.getAttribute("data-href"), "bar") // Ensure the update method was called ist(updates.length, 1) ist(updates[0], "bar") }) it("rebuilds a mark view that provides no update method", () => { let view = tempEditor({ doc: doc(p(a("foo"))), markViews: {link(mark) { let dom = document.createElement("span") dom.setAttribute("data-href", mark.attrs.href) return {dom} }} }) let span = view.dom.querySelector("span")! let link = view.state.schema.marks.link view.dispatch(view.state.tr.removeMark(1, 4, link).addMark(1, 4, link.create({ href: "bar" }))) // Ensure that the mark view was rebuilt ist(view.dom.querySelector("span") !== span) // Ensure the DOM was updated ist(view.dom.querySelector("span")!.getAttribute("data-href"), "bar") }) }) prosemirror-view/test/webtest-nodeview.ts000066400000000000000000000147051527132543100212450ustar00rootroot00000000000000import {doc, p, br, blockquote} from "prosemirror-test-builder" import {Plugin} from "prosemirror-state" import {DecorationSet, Decoration, ViewMutationRecord} from "prosemirror-view" import ist from "ist" import {tempEditor, flush} from "./view.js" describe("nodeViews prop", () => { it("can replace a node's representation", () => { let view = tempEditor({doc: doc(p("foo", br())), nodeViews: {hard_break() { return {dom: document.createElement("var")}}}}) ist(view.dom.querySelector("var")) }) it("can override drawing of a node's content", () => { let view = tempEditor({ doc: doc(p("foo")), nodeViews: {paragraph(node) { let dom = document.createElement("p") dom.textContent = node.textContent.toUpperCase() return {dom} }} }) ist(view.dom.querySelector("p")!.textContent, "FOO") view.dispatch(view.state.tr.insertText("a")) ist(view.dom.querySelector("p")!.textContent, "AFOO") }) it("can register its own update method", () => { let view = tempEditor({ doc: doc(p("foo")), nodeViews: {paragraph(node) { let dom = document.createElement("p") dom.textContent = node.textContent.toUpperCase() return {dom, update(node) { dom.textContent = node.textContent.toUpperCase(); return true }} }} }) let para = view.dom.querySelector("p")! view.dispatch(view.state.tr.insertText("a")) ist(view.dom.querySelector("p"), para) ist(para.textContent, "AFOO") }) it("allows decoration updates for node views with an update method", () => { let view = tempEditor({ doc: doc(p("foo")), nodeViews: {paragraph(node) { let dom = document.createElement("p") return {dom, contentDOM: dom, update(node_) { return node.sameMarkup(node_) }} }} }) view.setProps({ decorations(state) { return DecorationSet.create(state.doc, [ Decoration.inline(2, 3, {someattr: "ok"}), Decoration.node(0, 5, {otherattr: "ok"}) ]) } }) ist(view.dom.querySelector("[someattr]")) ist(view.dom.querySelector("[otherattr]")) }) it("can provide a contentDOM property", () => { let view = tempEditor({ doc: doc(p("foo")), nodeViews: {paragraph() { let dom = document.createElement("p") return {dom, contentDOM: dom} }} }) let para = view.dom.querySelector("p")! view.dispatch(view.state.tr.insertText("a")) ist(view.dom.querySelector("p"), para) ist(para.textContent, "afoo") }) it("has its ignoreMutation method called", async () => { let mutation: ViewMutationRecord | undefined let view = tempEditor({ doc: doc(p("foo")), nodeViews: {paragraph() { let dom = document.createElement('div'); let contentDOM = document.createElement('p'); let info = document.createElement('x-info') dom.append(contentDOM, info) return { dom, contentDOM, ignoreMutation: (m) => { mutation = m return true } } }} }) ist(!mutation) view.dom.querySelector("x-info")!.textContent = "info" flush(view) ist(mutation) ist((mutation!.target as HTMLElement).tagName, "X-INFO") }) it("has its destroy method called", () => { let destroyed = false, view = tempEditor({ doc: doc(p("foo", br())), nodeViews: {hard_break() { return {dom: document.createElement("br"), destroy: () => destroyed = true}}} }) ist(!destroyed) view.dispatch(view.state.tr.delete(3, 5)) ist(destroyed) }) it("can query its own position", () => { let get: () => number | undefined, view = tempEditor({ doc: doc(blockquote(p("abc"), p("foo", br()))), nodeViews: {hard_break(_n, _v, getPos) { ist(getPos(), 10) get = getPos return {dom: document.createElement("br")} }} }) ist(get!(), 10) view.dispatch(view.state.tr.insertText("a")) ist(get!(), 11) }) it("has access to outer decorations", () => { let plugin = new Plugin({ state: { init() { return null }, apply(tr, prev) { return tr.getMeta("setDeco") || prev } }, props: { decorations(this: Plugin, state) { let deco = this.getState(state) return deco && DecorationSet.create(state.doc, [ Decoration.inline(0, state.doc.content.size, {}, {name: deco} as any) ]) } } }) let view = tempEditor({ doc: doc(p("foo", br())), plugins: [plugin], nodeViews: {hard_break(_n, _v, _p, deco) { let dom = document.createElement("var") function update(deco: readonly Decoration[]) { dom.textContent = deco.length ? deco[0].spec.name : "[]" } update(deco) return {dom, update(_, deco) { update(deco); return true }} }} }) ist(view.dom.querySelector("var")!.textContent, "[]") view.dispatch(view.state.tr.setMeta("setDeco", "foo")) ist(view.dom.querySelector("var")!.textContent, "foo") view.dispatch(view.state.tr.setMeta("setDeco", "bar")) ist(view.dom.querySelector("var")!.textContent, "bar") }) it("provides access to inner decorations in the constructor", () => { tempEditor({ doc: doc(p("foo")), nodeViews: {paragraph(_node, _v, _pos, _outer, innerDeco) { let dom = document.createElement("p") ist((innerDeco as DecorationSet).find().map(d => `${d.from}-${d.to}`).join(), "1-2") return {dom, contentDOM: dom} }}, decorations(state) { return DecorationSet.create(state.doc, [ Decoration.inline(2, 3, {someattr: "ok"}), Decoration.node(0, 5, {otherattr: "ok"}) ]) } }) }) it("provides access to inner decorations in the update method", () => { let innerDecos: string[] = [] let view = tempEditor({ doc: doc(p("foo")), nodeViews: {paragraph(node) { let dom = document.createElement("p") return {dom, contentDOM: dom, update(node_, _, innerDecoSet) { innerDecos = (innerDecoSet as DecorationSet).find().map(d => `${d.from}-${d.to}`) return node.sameMarkup(node_) }} }} }) view.setProps({ decorations(state) { return DecorationSet.create(state.doc, [ Decoration.inline(2, 3, {someattr: "ok"}), Decoration.node(0, 5, {otherattr: "ok"}) ]) } }) ist(innerDecos.join(), "1-2") }) }) prosemirror-view/test/webtest-selection.ts000066400000000000000000000317671527132543100214210ustar00rootroot00000000000000import {doc, blockquote, p, em, img as img_, strong, code, code_block, br, hr, ul, li} from "prosemirror-test-builder" import ist from "ist" import {Selection, NodeSelection} from "prosemirror-state" import {Decoration, DecorationSet, EditorView} from "prosemirror-view" import {Node as PMNode} from "prosemirror-model" import {tempEditor, findTextNode, flush} from "./view.js" const img = img_({src: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAVFBMVEUAAAD/zE3/zE3/zE3/zE3/zE3/zE3/zE3/zE3/zE3BlS5mRQBtSwSFYA+ddhyRaxW0pILg2sz///9zUAa3jCl6VwprSgPxwEb+y0zgsT60iieYcRk7fYG6AAAACnRSTlMAOYbF8wAUjv+pYbspWwAAAHJJREFUGBkFwQlKQDEUBLBM+z64gPc/pyAK1o5JIEm0HeSRBNDWkh2A7FhSa8FaGuuEBBJyVgCArAAAmaCgkIELLpgyXUFvjk7ZZyfau49O+X39AS/fdKa5ecuX9/bq5Mn+SBJt+/nXPJIA2hrak0TbwT8U8zdOEulMLAAAAABJRU5ErkJggg=="}) const style = document.head.appendChild(document.createElement("style")) style.textContent = "img[src] { min-height: 10px }" function allPositions(doc: PMNode) { let found: number[] = [] function scan(node: PMNode, start: number) { if (node.isTextblock) { for (let i = 0; i <= node.content.size; i++) found.push(start + i) } else { node.forEach((child, offset) => scan(child, start + offset + 1)) } } scan(doc, 0) return found } 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 getSel() { let sel = window.getSelection()! let node = sel.focusNode, offset = sel.focusOffset while (node && node.nodeType != 3) { let after = offset < node.childNodes.length && node.childNodes[offset] let before = offset > 0 && node.childNodes[offset - 1] if (after) { node = after; offset = 0 } else if (before) { node = before; offset = node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length } else break } return {node: node, offset: offset} } function setSel(view: EditorView, sel: number | Selection) { if (typeof sel == "number") sel = Selection.near(view.state.doc.resolve(sel)) view.dispatch(view.state.tr.setSelection(sel)) } function event(code: number) { let event = document.createEvent("Event") event.initEvent("keydown", true, true) ;(event as any).keyCode = code return event } const LEFT = 37, RIGHT = 39, UP = 38, DOWN = 40 if (!document.hasFocus()) console["warn"]("Document doesn't have focus. Skipping some tests.") describe("EditorView", () => { it("can read the DOM selection", () => { // disabled when the document doesn't have focus, since that causes this to fail if (!document.hasFocus()) return let view = tempEditor({doc: doc(p("one"), hr(), blockquote(p("two")))}) function test(node: Node, offset: number, expected: number) { setDOMSel(node, offset) view.dom.focus() flush(view) let sel = view.state.selection ist(sel.head == null ? sel.from : sel.head, expected) } let one = findTextNode(view.dom, "one")! let two = findTextNode(view.dom, "two")! test(one, 0, 1) test(one, 1, 2) test(one, 3, 4) test(one.parentNode!, 0, 1) test(one.parentNode!, 1, 4) test(two, 0, 8) test(two, 3, 11) test(two.parentNode!, 1, 11) test(view.dom, 1, 4) test(view.dom, 2, 8) test(view.dom, 3, 11) }) it("syncs the DOM selection with the editor selection", () => { // disabled when the document doesn't have focus, since that causes this to fail if (!document.hasFocus()) return let view = tempEditor({doc: doc(p("one"), hr(), blockquote(p("two")))}) function test(pos: number, node: Node, offset: number) { setSel(view, pos) let sel = getSel() ist(sel.node, node) ist(sel.offset, offset) } let one = findTextNode(view.dom, "one")! let two = findTextNode(view.dom, "two")! view.focus() test(1, one, 0) test(2, one, 1) test(4, one, 3) test(8, two, 0) test(10, two, 2) }) it("returns sensible screen coordinates", () => { let view = tempEditor({doc: doc(p("one"), p("two"))}) let p00 = view.coordsAtPos(1) let p01 = view.coordsAtPos(2) let p03 = view.coordsAtPos(4) let p10 = view.coordsAtPos(6) let p13 = view.coordsAtPos(9) ist(p00.bottom, p00.top, ">") ist(p13.bottom, p13.top, ">") ist(p00.top, p01.top) ist(p01.top, p03.top) ist(p00.bottom, p03.bottom) ist(p10.top, p13.top) ist(p01.left, p00.left, ">") ist(p03.left, p01.left, ">") ist(p10.top, p00.top, ">") ist(p13.left, p10.left, ">") }) it("returns proper coordinates in code blocks", () => { let view = tempEditor({doc: doc(code_block("a\nb\n"))}), p = [] for (let i = 1; i <= 5; i++) p.push(view.coordsAtPos(i)) let [p0, p1, p2, p3, p4] = p ist(p0.top, p1.top) ist(p0.left, p1.left, "<") ist(p2.top, p1.top, ">") ist(p2.top, p3.top) ist(p2.left, p3.left, "<") ist(p2.left, p0.left) ist(p4.top, p3.top, ">") // This one shows a small (0.01 pixel) difference in Firefox for // some reason. ist(Math.round(p4.left), Math.round(p2.left)) }) it("produces sensible screen coordinates in corner cases", () => { let view = tempEditor({doc: doc(p("one", em("two", strong("three"), img), br(), code("foo")), p())}) return new Promise(ok => { setTimeout(() => { allPositions(view.state.doc).forEach(pos => { let coords = view.coordsAtPos(pos) let found = view.posAtCoords({top: coords.top + 1, left: coords.left})!.pos ist(found, pos) setSel(view, pos) }) ok(null) }, 20) }) }) it("doesn't return zero-height rectangles after leaves", () => { let view = tempEditor({doc: doc(p(img))}) let coords = view.coordsAtPos(2, 1) ist(coords.bottom - coords.top, 5, ">") }) it("produces horizontal rectangles for positions between blocks", () => { let view = tempEditor({doc: doc(p("ha"), hr(), blockquote(p("ba")))}) let a = view.coordsAtPos(0) ist(a.top, a.bottom) ist(a.top, (view.dom.firstChild as HTMLElement).getBoundingClientRect().top) ist(a.left, a.right, "<") let b = view.coordsAtPos(4) ist(b.top, b.bottom) ist(b.top, a.top, ">") ist(b.left, b.right, "<") let c = view.coordsAtPos(5) ist(c.top, c.bottom) ist(c.top, b.top, ">") let d = view.coordsAtPos(6) ist(d.top, d.bottom) ist(d.left, d.right, "<") ist(d.top, view.dom.getBoundingClientRect().bottom, "<") }) it("produces sensible screen coordinates around line breaks", () => { let view = tempEditor({doc: doc(p("one two three four five-six-seven-eight"))}) function afterSpace(pos: number) { return pos > 0 && view.state.doc.textBetween(pos - 1, pos) == " " } view.dom.style.width = "4em" let prevBefore: {left: number, top: number, right: number, bottom: number} | undefined let prevAfter: {left: number, top: number, right: number, bottom: number} | undefined allPositions(view.state.doc).forEach(pos => { let coords = view.coordsAtPos(pos, 1) if (prevAfter) ist(prevAfter.top < coords.top || prevAfter.top == coords.top && prevAfter.left < coords.left) prevAfter = coords let found = view.posAtCoords({top: coords.top + 1, left: coords.left})!.pos ist(found, pos) let coordsBefore = view.coordsAtPos(pos, -1) if (prevBefore) ist(prevBefore.top < coordsBefore.top || prevBefore.top == coordsBefore.top && (prevBefore.left < coordsBefore.left || (afterSpace(pos) && prevBefore.left == coordsBefore.left))) prevBefore = coordsBefore }) }) it("can find coordinates on node boundaries", () => { let view = tempEditor({doc: doc(p("one ", em("two"), " ", em(strong("three"))))}) let prev: {left: number, top: number, right: number, bottom: number} allPositions(view.state.doc).forEach(pos => { let coords = view.coordsAtPos(pos, 1) if (prev) ist(prev.top < coords.top || Math.abs(prev.top - coords.top) < 4 && prev.left < coords.left) prev = coords }) }) it("finds proper coordinates in RTL text", () => { let view = tempEditor({doc: doc(p("مرآة نثرية"))}) view.dom.style.direction = "rtl" let prev: {left: number, top: number, right: number, bottom: number} allPositions(view.state.doc).forEach(pos => { let coords = view.coordsAtPos(pos, 1) if (prev) ist(prev.top < coords.top || Math.abs(prev.top - coords.top) < 4 && prev.left > coords.left) prev = coords }) }) it("can go back and forth between screen coordsa and document positions", () => { let view = tempEditor({doc: doc(p("one"), blockquote(p("two"), p("three")))}) ;[1, 2, 4, 7, 14, 15].forEach(pos => { let coords = view.coordsAtPos(pos) let found = view.posAtCoords({top: coords.top + 1, left: coords.left})!.pos ist(found, pos) }) }) it("returns correct screen coordinates for wrapped lines", () => { let view = tempEditor({}) let top = view.coordsAtPos(1), pos = 1, end: {left: number, top: number, right: number, bottom: number} | undefined for (let i = 0; i < 100; i++) { view.dispatch(view.state.tr.insertText("a bc de fg h")) pos += 12 end = view.coordsAtPos(pos)! if (end.bottom > top.bottom + 4) break } ist(view.posAtCoords({left: end!.left + 50, top: end!.top + 5})!.pos, pos) }) it("makes arrow motion go through selectable inline nodes", () => { let view = tempEditor({doc: doc(p("foo", img, "bar"))}) view.dispatchEvent(event(RIGHT)) ist(view.state.selection.from, 4) view.dispatchEvent(event(RIGHT)) ist(view.state.selection.head, 5) ist(view.state.selection.anchor, 5) view.dispatchEvent(event(LEFT)) ist(view.state.selection.from, 4) view.dispatchEvent(event(LEFT)) ist(view.state.selection.head, 4) ist(view.state.selection.anchor, 4) }) it("makes arrow motion go through selectable block nodes", () => { let view = tempEditor({doc: doc(p("hello"), hr(), ul(li(p("there"))))}) view.dispatchEvent(event(DOWN)) ist(view.state.selection.from, 7) setSel(view, 11) view.dispatchEvent(event(UP)) ist(view.state.selection.from, 7) }) it("supports arrow motion through adjacent blocks", () => { let view = tempEditor({doc: doc(blockquote(p("hello")), hr(), hr(), p("there"))}) view.dispatchEvent(event(DOWN)) ist(view.state.selection.from, 9) view.dispatchEvent(event(DOWN)) ist(view.state.selection.from, 10) setSel(view, 14) view.dispatchEvent(event(UP)) ist(view.state.selection.from, 10) view.dispatchEvent(event(UP)) ist(view.state.selection.from, 9) }) it("support horizontal motion through blocks", () => { let view = tempEditor({doc: doc(p("foo"), hr(), hr(), p("bar"))}) view.dispatchEvent(event(RIGHT)) ist(view.state.selection.from, 5) view.dispatchEvent(event(RIGHT)) ist(view.state.selection.from, 6) view.dispatchEvent(event(RIGHT)) ist(view.state.selection.head, 8) view.dispatchEvent(event(LEFT)) ist(view.state.selection.from, 6) view.dispatchEvent(event(LEFT)) ist(view.state.selection.from, 5) view.dispatchEvent(event(LEFT)) ist(view.state.selection.head, 4) }) it("allows moving directly from an inline node to a block node", () => { let view = tempEditor({doc: doc(p("foo", img), hr(), p(img, "bar"))}) setSel(view, NodeSelection.create(view.state.doc, 4)) view.dispatchEvent(event(DOWN)) ist(view.state.selection.from, 6) setSel(view, NodeSelection.create(view.state.doc, 8)) view.dispatchEvent(event(UP)) ist(view.state.selection.from, 6) }) it("updates the selection even if the DOM parameters look unchanged", () => { if (!document.hasFocus()) return let view = tempEditor({doc: doc(p("foobar"))}) view.focus() let decos = DecorationSet.create(view.state.doc, [Decoration.inline(1, 4, {color: "green"})]) view.setProps({decorations() { return decos }}) view.setProps({decorations: undefined}) view.setProps({decorations() { return decos }}) let range = document.createRange() range.setEnd(document.getSelection()!.anchorNode!, document.getSelection()!.anchorOffset) range.setStart(view.dom, 0) ist(range.toString(), "foobar") }) it("sets selection even if Selection.extend throws DOMException", () => { let originalExtend = window.Selection.prototype.extend window.Selection.prototype.extend = () => { // declare global: DOMException throw new DOMException("failed") } try { let view = tempEditor({doc: doc(p("foo", img), hr(), p(img, "bar"))}) setSel(view, NodeSelection.create(view.state.doc, 4)) view.dispatchEvent(event(DOWN)) ist(view.state.selection.from, 6) } finally { window.Selection.prototype.extend = originalExtend } }) it("doesn't put the cursor after BR hack nodes", () => { if (!document.hasFocus()) return let view = tempEditor({doc: doc(p())}) view.focus() ist(getSelection()!.focusOffset, 0) }) }) prosemirror-view/test/webtest-view.ts000066400000000000000000000076561527132543100204060ustar00rootroot00000000000000import {schema, doc, ul, li, p, strong, em, hr} from "prosemirror-test-builder" import {EditorState} from "prosemirror-state" import {Schema} from "prosemirror-model" import {EditorView} from "prosemirror-view" import ist from "ist" import {tempEditor} from "./view.js" let space = document.querySelector("#workspace")! describe("EditorView", () => { it("can mount an existing node", () => { let dom = space.appendChild(document.createElement("article")) let view = new EditorView({mount: dom}, { state: EditorState.create({doc: doc(p("hi"))}) }) ist(view.dom, dom) ist(dom.contentEditable, "true") ist(dom.firstChild!.nodeName, "P") view.destroy() ist(dom.contentEditable, "inherit") space.removeChild(dom) }) it("reflects the current state in .props", () => { let view = tempEditor() ist(view.state, view.props.state) view.dispatch(view.state.tr.insertText("x")) ist(view.state, view.props.state) }) it("can update props with setProp", () => { let view = tempEditor({scrollThreshold: 100}) view.setProps({ scrollThreshold: undefined, scrollMargin: 10, state: view.state.apply(view.state.tr.insertText("y")) }) ist(view.state.doc.content.size, 3) ist(view.props.scrollThreshold, null) ist(view.props.scrollMargin, 10) }) it("can update with a state using a different schema", () => { let testSchema = new Schema({nodes: schema.spec.nodes}) let view = tempEditor({doc: doc(p(strong("foo")))}) view.updateState(EditorState.create({doc: (testSchema.nodes as any).doc.createAndFill()})) ist(!view.dom.querySelector("strong")) }) it("calls handleScrollToSelection when appropriate", () => { let called = 0 let view = tempEditor({ doc: doc(p("foo")), handleScrollToSelection() { called++; return false } }) view.focus() view.dispatch(view.state.tr.scrollIntoView()) ist(called, 1) }) it("can be queried for the DOM position at a doc position", () => { let view = tempEditor({doc: doc(ul(li(p(strong("foo")))))}) let inText = view.domAtPos(4) ist(inText.offset, 1) ist(inText.node.nodeValue, "foo") let beforeLI = view.domAtPos(1) ist(beforeLI.offset, 0) ist(beforeLI.node.nodeName, "UL") let afterP = view.domAtPos(7) ist(afterP.offset, 1) ist(afterP.node.nodeName, "LI") }) it("can bias DOM position queries to enter nodes", () => { let view = tempEditor({doc: doc(p(em(strong("a"), "b"), "c"))}) let get = (pos: number, bias: number) => { let r = view.domAtPos(pos, bias) return (r.node.nodeType == 1 ? r.node.nodeName : r.node.nodeValue) + "@" + r.offset } ist(get(1, 0), "P@0") ist(get(1, -1), "P@0") ist(get(1, 1), "a@0") ist(get(2, -1), "a@1") ist(get(2, 0), "EM@1") ist(get(2, 1), "b@0") ist(get(3, -1), "b@1") ist(get(3, 0), "P@1") ist(get(3, 1), "c@0") ist(get(4, -1), "c@1") ist(get(4, 0), "P@2") ist(get(4, 1), "P@2") }) it("can be queried for a node's DOM representation", () => { let view = tempEditor({doc: doc(p("foo"), hr())}) ist(view.nodeDOM(0)!.nodeName, "P") ist(view.nodeDOM(5)!.nodeName, "HR") ist(view.nodeDOM(3), null) }) it("can map DOM positions to doc positions", () => { let view = tempEditor({doc: doc(p("foo"), hr())}) ist(view.posAtDOM(view.dom.firstChild!.firstChild!, 2), 3) ist(view.posAtDOM(view.dom, 1), 5) ist(view.posAtDOM(view.dom, 2), 6) ist(view.posAtDOM(view.dom.lastChild!, 0, -1), 5) ist(view.posAtDOM(view.dom.lastChild!, 0, 1), 6) }) it("binds this to itself in dispatchTransaction prop", () => { const dom = document.createElement("div") let thisBinding let view = new EditorView(dom, { state: EditorState.create({doc: doc(p("hi"))}), dispatchTransaction: function() { thisBinding = this } }) view.dispatch(view.state.tr.insertText("x")) ist(view, thisBinding) }) })