gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery
This commit is contained in:
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"libs": ["browser"],
|
||||
"plugins": {
|
||||
"node": {},
|
||||
"complete_strings": {},
|
||||
"es_modules": {}
|
||||
}
|
||||
}
|
||||
+2439
File diff suppressed because it is too large
Load Diff
+104
@@ -0,0 +1,104 @@
|
||||
# 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
|
||||
[GitHub issue tracker](http://github.com/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
|
||||
|
||||
If you want to make a change that involves a significant overhaul of
|
||||
the code or introduces a user-visible new feature, create an
|
||||
[RFC](https://github.com/ProseMirror/rfcs/) first with your proposal.
|
||||
|
||||
- Make sure you have a [GitHub Account](https://github.com/signup/free)
|
||||
|
||||
- Fork the relevant repository
|
||||
([how to fork a repo](https://help.github.com/articles/fork-a-repo))
|
||||
|
||||
- Create a local checkout of the code. You can use the
|
||||
[main repository](https://github.com/prosemirror/prosemirror) to
|
||||
easily check out all core modules.
|
||||
|
||||
- Make your changes, and commit them
|
||||
|
||||
- Follow the code style of the rest of the project (see below). Run
|
||||
`npm run lint` (in the main repository checkout) to make sure that
|
||||
the linter is happy.
|
||||
|
||||
- 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 ([how to create a pull request](https://help.github.com/articles/fork-a-repo)).
|
||||
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://github.com/ProseMirror/prosemirror/blob/master/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](https://github.com/marijnh/getdocs)-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.
|
||||
|
||||
- The linter (`npm run lint`) complains about unused variables and
|
||||
functions. Prefix their names with an underscore to muffle it.
|
||||
|
||||
- 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.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> 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.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# prosemirror-view
|
||||
|
||||
[ [**WEBSITE**](https://prosemirror.net) | [**ISSUES**](https://github.com/prosemirror/prosemirror/issues) | [**FORUM**](https://discuss.prosemirror.net) | [**CHANGELOG**](https://github.com/ProseMirror/prosemirror-view/blob/master/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://github.com/prosemirror/prosemirror/tree/master/LICENSE).
|
||||
There's a [forum](http://discuss.prosemirror.net) for general
|
||||
discussion and support requests, and the
|
||||
[GitHub bug tracker](https://github.com/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.
|
||||
+5375
File diff suppressed because it is too large
Load Diff
+904
@@ -0,0 +1,904 @@
|
||||
import { EditorState, Transaction, Selection, Plugin } from 'prosemirror-state';
|
||||
import { Mark, Node, Slice, ResolvedPos, DOMParser, DOMSerializer } from 'prosemirror-model';
|
||||
import { Mapping } from 'prosemirror-transform';
|
||||
|
||||
type DOMNode = InstanceType<typeof window.Node>;
|
||||
|
||||
type WidgetConstructor = ((view: EditorView, getPos: () => number | undefined) => DOMNode) | DOMNode;
|
||||
/**
|
||||
Decoration objects can be provided to the view through the
|
||||
[`decorations` prop](https://prosemirror.net/docs/ref/#view.EditorProps.decorations). They come in
|
||||
several variants—see the static members of this class for details.
|
||||
*/
|
||||
declare class Decoration {
|
||||
/**
|
||||
The start position of the decoration.
|
||||
*/
|
||||
readonly from: number;
|
||||
/**
|
||||
The end position. Will be the same as `from` for [widget
|
||||
decorations](https://prosemirror.net/docs/ref/#view.Decoration^widget).
|
||||
*/
|
||||
readonly to: number;
|
||||
/**
|
||||
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`](https://prosemirror.net/docs/ref/#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;
|
||||
/**
|
||||
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](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#view.Decoration^inline^spec.inclusiveStart).
|
||||
*/
|
||||
inclusiveEnd?: boolean;
|
||||
/**
|
||||
Specs may have arbitrary additional properties.
|
||||
*/
|
||||
[key: string]: any;
|
||||
}): Decoration;
|
||||
/**
|
||||
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): Decoration;
|
||||
/**
|
||||
The spec provided when creating this decoration. Can be useful
|
||||
if you've stored extra information in that object.
|
||||
*/
|
||||
get spec(): any;
|
||||
}
|
||||
/**
|
||||
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:
|
||||
*/
|
||||
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;
|
||||
};
|
||||
/**
|
||||
An object that can [provide](https://prosemirror.net/docs/ref/#view.EditorProps.decorations)
|
||||
decorations. Implemented by [`DecorationSet`](https://prosemirror.net/docs/ref/#view.DecorationSet),
|
||||
and passed to [node views](https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews).
|
||||
*/
|
||||
interface DecorationSource {
|
||||
/**
|
||||
Map the set of decorations in response to a change in the
|
||||
document.
|
||||
*/
|
||||
map: (mapping: Mapping, node: Node) => DecorationSource;
|
||||
/**
|
||||
Extract a DecorationSource containing decorations for the given child node at the given offset.
|
||||
*/
|
||||
forChild(offset: number, child: Node): DecorationSource;
|
||||
/**
|
||||
Call the given function for each decoration set in the group.
|
||||
*/
|
||||
forEachSet(f: (set: DecorationSet) => void): void;
|
||||
}
|
||||
/**
|
||||
A collection of [decorations](https://prosemirror.net/docs/ref/#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.
|
||||
*/
|
||||
declare class DecorationSet implements DecorationSource {
|
||||
/**
|
||||
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[]): DecorationSet;
|
||||
/**
|
||||
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[];
|
||||
private findInner;
|
||||
/**
|
||||
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;
|
||||
}): DecorationSet;
|
||||
/**
|
||||
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[]): DecorationSet;
|
||||
private addInner;
|
||||
/**
|
||||
Create a new set that contains the decorations in this set, minus
|
||||
the ones in the given array.
|
||||
*/
|
||||
remove(decorations: Decoration[]): DecorationSet;
|
||||
private removeInner;
|
||||
forChild(offset: number, node: Node): DecorationSet | DecorationGroup;
|
||||
/**
|
||||
The empty set of decorations.
|
||||
*/
|
||||
static empty: DecorationSet;
|
||||
forEachSet(f: (set: DecorationSet) => void): void;
|
||||
}
|
||||
declare class DecorationGroup implements DecorationSource {
|
||||
readonly members: readonly DecorationSet[];
|
||||
constructor(members: readonly DecorationSet[]);
|
||||
map(mapping: Mapping, doc: Node): DecorationSource;
|
||||
forChild(offset: number, child: Node): DecorationSource | DecorationSet;
|
||||
eq(other: DecorationGroup): boolean;
|
||||
locals(node: Node): readonly any[];
|
||||
static from(members: readonly DecorationSource[]): DecorationSource;
|
||||
forEachSet(f: (set: DecorationSet) => void): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Node {
|
||||
}
|
||||
}
|
||||
/**
|
||||
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).
|
||||
*/
|
||||
type ViewMutationRecord = MutationRecord | {
|
||||
type: "selection";
|
||||
target: DOMNode;
|
||||
};
|
||||
/**
|
||||
By default, document nodes are rendered using the result of the
|
||||
[`toDOM`](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews) a custom node view.
|
||||
|
||||
Objects returned as node views must conform to this interface.
|
||||
*/
|
||||
interface NodeView {
|
||||
/**
|
||||
The outer DOM node that represents the document node.
|
||||
*/
|
||||
dom: DOMNode;
|
||||
/**
|
||||
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](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#view.EditorProps.markViews) a custom mark view.
|
||||
|
||||
Objects returned as mark views must conform to this interface.
|
||||
*/
|
||||
interface MarkView {
|
||||
/**
|
||||
The outer DOM node that represents the document node.
|
||||
*/
|
||||
dom: DOMNode;
|
||||
/**
|
||||
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;
|
||||
/**
|
||||
Called when a [mutation](https://prosemirror.net/docs/ref/#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;
|
||||
}
|
||||
|
||||
/**
|
||||
An editor view manages the DOM structure that represents an
|
||||
editable document. Its state and behavior are determined by its
|
||||
[props](https://prosemirror.net/docs/ref/#view.DirectEditorProps).
|
||||
*/
|
||||
declare class EditorView {
|
||||
private directPlugins;
|
||||
private _root;
|
||||
private mounted;
|
||||
private prevDirectPlugins;
|
||||
private pluginViews;
|
||||
/**
|
||||
The view's current [state](https://prosemirror.net/docs/ref/#state.EditorState).
|
||||
*/
|
||||
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);
|
||||
/**
|
||||
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](https://prosemirror.net/docs/ref/#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;
|
||||
};
|
||||
/**
|
||||
Holds `true` when a
|
||||
[composition](https://w3c.github.io/uievents/#events-compositionevents)
|
||||
is active.
|
||||
*/
|
||||
get composing(): boolean;
|
||||
/**
|
||||
The view's current [props](https://prosemirror.net/docs/ref/#view.EditorProps).
|
||||
*/
|
||||
get props(): DirectEditorProps;
|
||||
/**
|
||||
Update the view's props. Will immediately cause an update to
|
||||
the DOM.
|
||||
*/
|
||||
update(props: DirectEditorProps): void;
|
||||
/**
|
||||
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<DirectEditorProps>): void;
|
||||
/**
|
||||
Update the editor's `state` prop, without touching any of the
|
||||
other props.
|
||||
*/
|
||||
updateState(state: EditorState): void;
|
||||
private updateStateInner;
|
||||
private destroyPluginViews;
|
||||
private updatePluginViews;
|
||||
private updateDraggedNode;
|
||||
/**
|
||||
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 extends keyof EditorProps, Result>(propName: PropName, f: (value: NonNullable<EditorProps[PropName]>) => Result): Result | undefined;
|
||||
someProp<PropName extends keyof EditorProps>(propName: PropName): NonNullable<EditorProps[PropName]> | undefined;
|
||||
/**
|
||||
Query whether the view has focus.
|
||||
*/
|
||||
hasFocus(): boolean;
|
||||
/**
|
||||
Focus the editor.
|
||||
*/
|
||||
focus(): void;
|
||||
/**
|
||||
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;
|
||||
/**
|
||||
When an existing editor view is moved to a new document or
|
||||
shadow tree, call this to make it recompute its root.
|
||||
*/
|
||||
updateRoot(): void;
|
||||
/**
|
||||
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;
|
||||
/**
|
||||
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?: number): {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
};
|
||||
/**
|
||||
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?: number): {
|
||||
node: DOMNode;
|
||||
offset: number;
|
||||
};
|
||||
/**
|
||||
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;
|
||||
/**
|
||||
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?: number): number;
|
||||
/**
|
||||
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;
|
||||
/**
|
||||
Run the editor's paste logic with the given HTML string. The
|
||||
`event`, if given, will be passed to the
|
||||
[`handlePaste`](https://prosemirror.net/docs/ref/#view.EditorProps.handlePaste) hook.
|
||||
*/
|
||||
pasteHTML(html: string, event?: ClipboardEvent): boolean;
|
||||
/**
|
||||
Run the editor's paste logic with the given plain-text input.
|
||||
*/
|
||||
pasteText(text: string, event?: ClipboardEvent): boolean;
|
||||
/**
|
||||
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`](https://prosemirror.net/docs/ref/#view.EditorProps.transformCopied)).
|
||||
*/
|
||||
serializeForClipboard(slice: Slice): {
|
||||
dom: HTMLElement;
|
||||
text: string;
|
||||
slice: Slice;
|
||||
};
|
||||
/**
|
||||
Removes the editor from the DOM and destroys all [node
|
||||
views](https://prosemirror.net/docs/ref/#view.NodeView).
|
||||
*/
|
||||
destroy(): void;
|
||||
/**
|
||||
This is true when the view has been
|
||||
[destroyed](https://prosemirror.net/docs/ref/#view.EditorView.destroy) (and thus should not be
|
||||
used anymore).
|
||||
*/
|
||||
get isDestroyed(): boolean;
|
||||
/**
|
||||
Used for testing.
|
||||
*/
|
||||
dispatchEvent(event: Event): void;
|
||||
/**
|
||||
Dispatch a transaction. Will call
|
||||
[`dispatchTransaction`](https://prosemirror.net/docs/ref/#view.DirectEditorProps.dispatchTransaction)
|
||||
when given, and otherwise defaults to applying the transaction to
|
||||
the current state and calling
|
||||
[`updateState`](https://prosemirror.net/docs/ref/#view.EditorView.updateState) with the result.
|
||||
This method is bound to the view instance, so that it can be
|
||||
easily passed around.
|
||||
*/
|
||||
dispatch: (tr: Transaction) => void;
|
||||
}
|
||||
/**
|
||||
The type of function [provided](https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews) to
|
||||
create [node views](https://prosemirror.net/docs/ref/#view.NodeView).
|
||||
*/
|
||||
type NodeViewConstructor = (node: Node, view: EditorView, getPos: () => number | undefined, decorations: readonly Decoration[], innerDecorations: DecorationSource) => NodeView;
|
||||
/**
|
||||
The function types [used](https://prosemirror.net/docs/ref/#view.EditorProps.markViews) to create
|
||||
mark views.
|
||||
*/
|
||||
type MarkViewConstructor = (mark: Mark, view: EditorView, inline: boolean) => MarkView;
|
||||
/**
|
||||
Helper type that maps event names to event object types, but
|
||||
includes events that TypeScript's HTMLElementEventMap doesn't know
|
||||
about.
|
||||
*/
|
||||
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](https://prosemirror.net/docs/ref/#state.Plugin).
|
||||
*/
|
||||
interface EditorProps<P = any> {
|
||||
/**
|
||||
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](https://prosemirror.net/docs/ref/#model.DOMParser) to use when reading editor changes
|
||||
from the DOM. Defaults to calling
|
||||
[`DOMParser.fromSchema`](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#model.DOMParser) to use when reading content from
|
||||
the clipboard. When not given, the value of the
|
||||
[`domParser`](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#view.EditorProps.transformPastedText).
|
||||
The default behavior is to split the text into lines, wrap them
|
||||
in `<p>` tags, and call
|
||||
[`clipboardParser`](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#model.DOMSerializer^fromSchema)
|
||||
will be used. This object will only have its
|
||||
[`serializeFragment`](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#model.Node.textBetween) on the
|
||||
selected range.
|
||||
*/
|
||||
clipboardTextSerializer?: (this: P, content: Slice, view: EditorView) => string;
|
||||
/**
|
||||
A set of [document decorations](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#view.EditorProps.editable). Additional classes
|
||||
provided here will be added to the class. For other attributes,
|
||||
the value provided first (as in
|
||||
[`someProp`](https://prosemirror.net/docs/ref/#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:
|
||||
*/
|
||||
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](https://prosemirror.net/docs/ref/#state.PluginSpec.view) and
|
||||
[props](https://prosemirror.net/docs/ref/#state.PluginSpec.props). Passing plugins with a state
|
||||
component (a [state field](https://prosemirror.net/docs/ref/#state.PluginSpec.state) field or a
|
||||
[transaction](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#view.EditorView.updateState) method with a new
|
||||
state that has the transaction
|
||||
[applied](https://prosemirror.net/docs/ref/#state.EditorState.apply). The callback will be bound to have
|
||||
the view instance as its `this` binding.
|
||||
*/
|
||||
dispatchTransaction?: (tr: Transaction) => void;
|
||||
}
|
||||
|
||||
export { type DOMEventMap, Decoration, type DecorationAttrs, DecorationSet, type DecorationSource, type DirectEditorProps, type EditorProps, EditorView, type MarkView, type MarkViewConstructor, type NodeView, type NodeViewConstructor, type ViewMutationRecord };
|
||||
+904
@@ -0,0 +1,904 @@
|
||||
import { EditorState, Transaction, Selection, Plugin } from 'prosemirror-state';
|
||||
import { Mark, Node, Slice, ResolvedPos, DOMParser, DOMSerializer } from 'prosemirror-model';
|
||||
import { Mapping } from 'prosemirror-transform';
|
||||
|
||||
type DOMNode = InstanceType<typeof window.Node>;
|
||||
|
||||
type WidgetConstructor = ((view: EditorView, getPos: () => number | undefined) => DOMNode) | DOMNode;
|
||||
/**
|
||||
Decoration objects can be provided to the view through the
|
||||
[`decorations` prop](https://prosemirror.net/docs/ref/#view.EditorProps.decorations). They come in
|
||||
several variants—see the static members of this class for details.
|
||||
*/
|
||||
declare class Decoration {
|
||||
/**
|
||||
The start position of the decoration.
|
||||
*/
|
||||
readonly from: number;
|
||||
/**
|
||||
The end position. Will be the same as `from` for [widget
|
||||
decorations](https://prosemirror.net/docs/ref/#view.Decoration^widget).
|
||||
*/
|
||||
readonly to: number;
|
||||
/**
|
||||
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`](https://prosemirror.net/docs/ref/#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;
|
||||
/**
|
||||
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](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#view.Decoration^inline^spec.inclusiveStart).
|
||||
*/
|
||||
inclusiveEnd?: boolean;
|
||||
/**
|
||||
Specs may have arbitrary additional properties.
|
||||
*/
|
||||
[key: string]: any;
|
||||
}): Decoration;
|
||||
/**
|
||||
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): Decoration;
|
||||
/**
|
||||
The spec provided when creating this decoration. Can be useful
|
||||
if you've stored extra information in that object.
|
||||
*/
|
||||
get spec(): any;
|
||||
}
|
||||
/**
|
||||
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:
|
||||
*/
|
||||
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;
|
||||
};
|
||||
/**
|
||||
An object that can [provide](https://prosemirror.net/docs/ref/#view.EditorProps.decorations)
|
||||
decorations. Implemented by [`DecorationSet`](https://prosemirror.net/docs/ref/#view.DecorationSet),
|
||||
and passed to [node views](https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews).
|
||||
*/
|
||||
interface DecorationSource {
|
||||
/**
|
||||
Map the set of decorations in response to a change in the
|
||||
document.
|
||||
*/
|
||||
map: (mapping: Mapping, node: Node) => DecorationSource;
|
||||
/**
|
||||
Extract a DecorationSource containing decorations for the given child node at the given offset.
|
||||
*/
|
||||
forChild(offset: number, child: Node): DecorationSource;
|
||||
/**
|
||||
Call the given function for each decoration set in the group.
|
||||
*/
|
||||
forEachSet(f: (set: DecorationSet) => void): void;
|
||||
}
|
||||
/**
|
||||
A collection of [decorations](https://prosemirror.net/docs/ref/#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.
|
||||
*/
|
||||
declare class DecorationSet implements DecorationSource {
|
||||
/**
|
||||
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[]): DecorationSet;
|
||||
/**
|
||||
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[];
|
||||
private findInner;
|
||||
/**
|
||||
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;
|
||||
}): DecorationSet;
|
||||
/**
|
||||
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[]): DecorationSet;
|
||||
private addInner;
|
||||
/**
|
||||
Create a new set that contains the decorations in this set, minus
|
||||
the ones in the given array.
|
||||
*/
|
||||
remove(decorations: Decoration[]): DecorationSet;
|
||||
private removeInner;
|
||||
forChild(offset: number, node: Node): DecorationSet | DecorationGroup;
|
||||
/**
|
||||
The empty set of decorations.
|
||||
*/
|
||||
static empty: DecorationSet;
|
||||
forEachSet(f: (set: DecorationSet) => void): void;
|
||||
}
|
||||
declare class DecorationGroup implements DecorationSource {
|
||||
readonly members: readonly DecorationSet[];
|
||||
constructor(members: readonly DecorationSet[]);
|
||||
map(mapping: Mapping, doc: Node): DecorationSource;
|
||||
forChild(offset: number, child: Node): DecorationSource | DecorationSet;
|
||||
eq(other: DecorationGroup): boolean;
|
||||
locals(node: Node): readonly any[];
|
||||
static from(members: readonly DecorationSource[]): DecorationSource;
|
||||
forEachSet(f: (set: DecorationSet) => void): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Node {
|
||||
}
|
||||
}
|
||||
/**
|
||||
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).
|
||||
*/
|
||||
type ViewMutationRecord = MutationRecord | {
|
||||
type: "selection";
|
||||
target: DOMNode;
|
||||
};
|
||||
/**
|
||||
By default, document nodes are rendered using the result of the
|
||||
[`toDOM`](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews) a custom node view.
|
||||
|
||||
Objects returned as node views must conform to this interface.
|
||||
*/
|
||||
interface NodeView {
|
||||
/**
|
||||
The outer DOM node that represents the document node.
|
||||
*/
|
||||
dom: DOMNode;
|
||||
/**
|
||||
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](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#view.EditorProps.markViews) a custom mark view.
|
||||
|
||||
Objects returned as mark views must conform to this interface.
|
||||
*/
|
||||
interface MarkView {
|
||||
/**
|
||||
The outer DOM node that represents the document node.
|
||||
*/
|
||||
dom: DOMNode;
|
||||
/**
|
||||
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;
|
||||
/**
|
||||
Called when a [mutation](https://prosemirror.net/docs/ref/#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;
|
||||
}
|
||||
|
||||
/**
|
||||
An editor view manages the DOM structure that represents an
|
||||
editable document. Its state and behavior are determined by its
|
||||
[props](https://prosemirror.net/docs/ref/#view.DirectEditorProps).
|
||||
*/
|
||||
declare class EditorView {
|
||||
private directPlugins;
|
||||
private _root;
|
||||
private mounted;
|
||||
private prevDirectPlugins;
|
||||
private pluginViews;
|
||||
/**
|
||||
The view's current [state](https://prosemirror.net/docs/ref/#state.EditorState).
|
||||
*/
|
||||
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);
|
||||
/**
|
||||
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](https://prosemirror.net/docs/ref/#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;
|
||||
};
|
||||
/**
|
||||
Holds `true` when a
|
||||
[composition](https://w3c.github.io/uievents/#events-compositionevents)
|
||||
is active.
|
||||
*/
|
||||
get composing(): boolean;
|
||||
/**
|
||||
The view's current [props](https://prosemirror.net/docs/ref/#view.EditorProps).
|
||||
*/
|
||||
get props(): DirectEditorProps;
|
||||
/**
|
||||
Update the view's props. Will immediately cause an update to
|
||||
the DOM.
|
||||
*/
|
||||
update(props: DirectEditorProps): void;
|
||||
/**
|
||||
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<DirectEditorProps>): void;
|
||||
/**
|
||||
Update the editor's `state` prop, without touching any of the
|
||||
other props.
|
||||
*/
|
||||
updateState(state: EditorState): void;
|
||||
private updateStateInner;
|
||||
private destroyPluginViews;
|
||||
private updatePluginViews;
|
||||
private updateDraggedNode;
|
||||
/**
|
||||
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 extends keyof EditorProps, Result>(propName: PropName, f: (value: NonNullable<EditorProps[PropName]>) => Result): Result | undefined;
|
||||
someProp<PropName extends keyof EditorProps>(propName: PropName): NonNullable<EditorProps[PropName]> | undefined;
|
||||
/**
|
||||
Query whether the view has focus.
|
||||
*/
|
||||
hasFocus(): boolean;
|
||||
/**
|
||||
Focus the editor.
|
||||
*/
|
||||
focus(): void;
|
||||
/**
|
||||
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;
|
||||
/**
|
||||
When an existing editor view is moved to a new document or
|
||||
shadow tree, call this to make it recompute its root.
|
||||
*/
|
||||
updateRoot(): void;
|
||||
/**
|
||||
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;
|
||||
/**
|
||||
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?: number): {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
};
|
||||
/**
|
||||
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?: number): {
|
||||
node: DOMNode;
|
||||
offset: number;
|
||||
};
|
||||
/**
|
||||
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;
|
||||
/**
|
||||
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?: number): number;
|
||||
/**
|
||||
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;
|
||||
/**
|
||||
Run the editor's paste logic with the given HTML string. The
|
||||
`event`, if given, will be passed to the
|
||||
[`handlePaste`](https://prosemirror.net/docs/ref/#view.EditorProps.handlePaste) hook.
|
||||
*/
|
||||
pasteHTML(html: string, event?: ClipboardEvent): boolean;
|
||||
/**
|
||||
Run the editor's paste logic with the given plain-text input.
|
||||
*/
|
||||
pasteText(text: string, event?: ClipboardEvent): boolean;
|
||||
/**
|
||||
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`](https://prosemirror.net/docs/ref/#view.EditorProps.transformCopied)).
|
||||
*/
|
||||
serializeForClipboard(slice: Slice): {
|
||||
dom: HTMLElement;
|
||||
text: string;
|
||||
slice: Slice;
|
||||
};
|
||||
/**
|
||||
Removes the editor from the DOM and destroys all [node
|
||||
views](https://prosemirror.net/docs/ref/#view.NodeView).
|
||||
*/
|
||||
destroy(): void;
|
||||
/**
|
||||
This is true when the view has been
|
||||
[destroyed](https://prosemirror.net/docs/ref/#view.EditorView.destroy) (and thus should not be
|
||||
used anymore).
|
||||
*/
|
||||
get isDestroyed(): boolean;
|
||||
/**
|
||||
Used for testing.
|
||||
*/
|
||||
dispatchEvent(event: Event): void;
|
||||
/**
|
||||
Dispatch a transaction. Will call
|
||||
[`dispatchTransaction`](https://prosemirror.net/docs/ref/#view.DirectEditorProps.dispatchTransaction)
|
||||
when given, and otherwise defaults to applying the transaction to
|
||||
the current state and calling
|
||||
[`updateState`](https://prosemirror.net/docs/ref/#view.EditorView.updateState) with the result.
|
||||
This method is bound to the view instance, so that it can be
|
||||
easily passed around.
|
||||
*/
|
||||
dispatch: (tr: Transaction) => void;
|
||||
}
|
||||
/**
|
||||
The type of function [provided](https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews) to
|
||||
create [node views](https://prosemirror.net/docs/ref/#view.NodeView).
|
||||
*/
|
||||
type NodeViewConstructor = (node: Node, view: EditorView, getPos: () => number | undefined, decorations: readonly Decoration[], innerDecorations: DecorationSource) => NodeView;
|
||||
/**
|
||||
The function types [used](https://prosemirror.net/docs/ref/#view.EditorProps.markViews) to create
|
||||
mark views.
|
||||
*/
|
||||
type MarkViewConstructor = (mark: Mark, view: EditorView, inline: boolean) => MarkView;
|
||||
/**
|
||||
Helper type that maps event names to event object types, but
|
||||
includes events that TypeScript's HTMLElementEventMap doesn't know
|
||||
about.
|
||||
*/
|
||||
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](https://prosemirror.net/docs/ref/#state.Plugin).
|
||||
*/
|
||||
interface EditorProps<P = any> {
|
||||
/**
|
||||
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](https://prosemirror.net/docs/ref/#model.DOMParser) to use when reading editor changes
|
||||
from the DOM. Defaults to calling
|
||||
[`DOMParser.fromSchema`](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#model.DOMParser) to use when reading content from
|
||||
the clipboard. When not given, the value of the
|
||||
[`domParser`](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#view.EditorProps.transformPastedText).
|
||||
The default behavior is to split the text into lines, wrap them
|
||||
in `<p>` tags, and call
|
||||
[`clipboardParser`](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#model.DOMSerializer^fromSchema)
|
||||
will be used. This object will only have its
|
||||
[`serializeFragment`](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#model.Node.textBetween) on the
|
||||
selected range.
|
||||
*/
|
||||
clipboardTextSerializer?: (this: P, content: Slice, view: EditorView) => string;
|
||||
/**
|
||||
A set of [document decorations](https://prosemirror.net/docs/ref/#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](https://prosemirror.net/docs/ref/#view.EditorProps.editable). Additional classes
|
||||
provided here will be added to the class. For other attributes,
|
||||
the value provided first (as in
|
||||
[`someProp`](https://prosemirror.net/docs/ref/#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:
|
||||
*/
|
||||
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](https://prosemirror.net/docs/ref/#state.PluginSpec.view) and
|
||||
[props](https://prosemirror.net/docs/ref/#state.PluginSpec.props). Passing plugins with a state
|
||||
component (a [state field](https://prosemirror.net/docs/ref/#state.PluginSpec.state) field or a
|
||||
[transaction](https://prosemirror.net/docs/ref/#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`](https://prosemirror.net/docs/ref/#view.EditorView.updateState) method with a new
|
||||
state that has the transaction
|
||||
[applied](https://prosemirror.net/docs/ref/#state.EditorState.apply). The callback will be bound to have
|
||||
the view instance as its `this` binding.
|
||||
*/
|
||||
dispatchTransaction?: (tr: Transaction) => void;
|
||||
}
|
||||
|
||||
export { type DOMEventMap, Decoration, type DecorationAttrs, DecorationSet, type DecorationSource, type DirectEditorProps, type EditorProps, EditorView, type MarkView, type MarkViewConstructor, type NodeView, type NodeViewConstructor, type ViewMutationRecord };
|
||||
+5895
File diff suppressed because it is too large
Load Diff
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "prosemirror-view",
|
||||
"version": "1.41.6",
|
||||
"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://github.com/prosemirror/prosemirror-view.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
ProseMirror'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
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
const 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
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
import {Selection, NodeSelection, TextSelection, AllSelection, EditorState} from "prosemirror-state"
|
||||
import {EditorView} from "./index"
|
||||
import * as browser from "./browser"
|
||||
import {domIndex, selectionCollapsed, hasBlockDesc} 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") > -1)) {
|
||||
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 if (browser.webkit) {
|
||||
// Chrome and Safari will introduce extra pointless cursor
|
||||
// positions around inline uneditable nodes, so we have to
|
||||
// take over and move the cursor past them (#937)
|
||||
return apply(view, new TextSelection(view.state.doc.resolve(dir < 0 ? nodePos : nodePos + node.nodeSize)))
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} 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 nodeLen(node: Node) {
|
||||
return node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length
|
||||
}
|
||||
|
||||
function isIgnorable(dom: Node, dir: number) {
|
||||
let desc = dom.pmViewDesc
|
||||
return desc && desc.size == 0 && (dir < 0 || dom.nextSibling || dom.nodeName != "BR")
|
||||
}
|
||||
|
||||
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 && node.nodeType == 1 && offset < nodeLen(node) && isIgnorable(node.childNodes[offset], -1)) force = true
|
||||
for (;;) {
|
||||
if (offset > 0) {
|
||||
if (node.nodeType != 1) {
|
||||
break
|
||||
} else {
|
||||
let before = node.childNodes[offset - 1]
|
||||
if (isIgnorable(before, -1)) {
|
||||
moveNode = node
|
||||
moveOffset = --offset
|
||||
} else if (before.nodeType == 3) {
|
||||
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 = nodeLen(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 = nodeLen(node)
|
||||
let moveNode, moveOffset: number | undefined
|
||||
for (;;) {
|
||||
if (offset < len) {
|
||||
if (node.nodeType != 1) 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 = nodeLen(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 (next.nodeType == 3) return next as Text
|
||||
if (next.nodeType == 1 && (next as HTMLElement).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 (next.nodeType == 3) return next as Text
|
||||
if (next.nodeType == 1 && (next as HTMLElement).contentEditable == "false") break
|
||||
node = next
|
||||
offset = node.childNodes.length
|
||||
}
|
||||
}
|
||||
|
||||
function setSelFocus(view: EditorView, node: Node, offset: number) {
|
||||
if (node.nodeType != 3) {
|
||||
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()
|
||||
if (!sel) return
|
||||
if (selectionCollapsed(sel)) {
|
||||
let range = document.createRange()
|
||||
range.setEnd(node, offset)
|
||||
range.setStart(node, offset)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
} else if (sel.extend) {
|
||||
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 computed = getComputedStyle(view.dom).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 && focusNode.nodeType == 1 && focusOffset == 0 &&
|
||||
focusNode.firstChild && (focusNode.firstChild as HTMLElement).contentEditable == "false") {
|
||||
let child = focusNode.firstChild as HTMLElement
|
||||
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
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
import {Slice, Fragment, DOMParser, DOMSerializer, ResolvedPos, NodeType, Node} from "prosemirror-model"
|
||||
import * as browser from "./browser"
|
||||
import {EditorView} from "./index"
|
||||
|
||||
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 && firstChild.nodeType == 1 && (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 && firstChild.nodeType == 1)
|
||||
(firstChild as HTMLElement).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 && child.nodeType != 1) 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 =
|
||||
// "<td>..</td>"` 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"]
|
||||
}
|
||||
|
||||
let _detachedDoc: Document | null = null
|
||||
function detachedDoc() {
|
||||
return _detachedDoc || (_detachedDoc = 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)
|
||||
_policy = trustedTypes.defaultPolicy || trustedTypes.createPolicy("ProseMirrorClipboard", {createHTML: (s: string) => s})
|
||||
return _policy.createHTML(html)
|
||||
}
|
||||
|
||||
function readHTML(html: string) {
|
||||
let metas = /^(\s*<meta [^>]*>)*/.exec(html)
|
||||
if (metas) html = html.slice(metas[0].length)
|
||||
let elt = detachedDoc().createElement("div")
|
||||
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 => "</" + n + ">").reverse().join("")
|
||||
elt.innerHTML = maybeWrapTrusted(html)
|
||||
if (wrap) for (let i = 0; i < wrap.length; i++) elt = elt.querySelector(wrap[i]) || elt
|
||||
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
|
||||
content = Fragment.from(type.create(array[i + 1], content))
|
||||
openStart++; openEnd++
|
||||
}
|
||||
return new Slice(content, openStart, openEnd)
|
||||
}
|
||||
+793
@@ -0,0 +1,793 @@
|
||||
import {Node, Mark} 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
|
||||
}
|
||||
|
||||
export interface DecorationType {
|
||||
spec: any
|
||||
map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null
|
||||
valid(node: Node, span: Decoration): 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): boolean {
|
||||
let {index, offset} = node.content.findIndex(span.from), child
|
||||
return offset == span.from && !(child = node.child(index)).isText && offset + child.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
|
||||
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)) (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)
|
||||
for (let i = 0; i < local.length; i++) if (!local[i].type.valid(doc, local[i])) 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<T>(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)
|
||||
for (let i = 0; i < locals.length; i++) if (!locals[i].type.valid(node, locals[i])) {
|
||||
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)
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
export type DOMNode = InstanceType<typeof window.Node>
|
||||
export type DOMSelection = InstanceType<typeof window.Selection>
|
||||
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 || parent.nodeType != 1 || hasBlockDesc(node) || atomElements.test(node.nodeName) ||
|
||||
(node as HTMLElement).contentEditable == "false")
|
||||
return false
|
||||
off = domIndex(node) + (dir < 0 ? 0 : 1)
|
||||
node = parent
|
||||
} else if (node.nodeType == 1) {
|
||||
let child = node.childNodes[off + (dir < 0 ? -1 : 0)]
|
||||
if (child.nodeType == 1 && (child as HTMLElement).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 textNodeBefore(node: Node, offset: number) {
|
||||
for (;;) {
|
||||
if (node.nodeType == 3 && offset) return node as Text
|
||||
if (node.nodeType == 1 && 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 (node.nodeType == 3 && offset < node.nodeValue!.length) return node as Text
|
||||
if (node.nodeType == 1 && offset < node.childNodes.length) {
|
||||
if ((node as HTMLElement).contentEditable == "false") return null
|
||||
node = node.childNodes[offset]
|
||||
offset = 0
|
||||
} else if (node.parentNode && !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 <input> 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)}
|
||||
}
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
import {Fragment, DOMParser, TagParseRule, Node, Mark, ResolvedPos} from "prosemirror-model"
|
||||
import {TextSelection, Transaction} from "prosemirror-state"
|
||||
|
||||
import {selectionBetween, selectionFromDOM, selectionToDOM} from "./selection"
|
||||
import {selectionCollapsed, keyEvent, DOMNode} from "./dom"
|
||||
import * as browser from "./browser"
|
||||
import {EditorView} from "./index"
|
||||
|
||||
// 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) {
|
||||
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 && view.dom.contains(anchor.nodeType == 1 ? anchor : anchor.parentNode)) {
|
||||
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,
|
||||
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}
|
||||
}
|
||||
|
||||
function ruleFromNode(dom: DOMNode): Omit<TagParseRule, "tag"> | null {
|
||||
let desc = dom.pmViewDesc
|
||||
if (desc) {
|
||||
return desc.parseRule()
|
||||
} 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 &&
|
||||
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "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)
|
||||
|
||||
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 => n.nodeType == 1 && !isInline.test(n.nodeName)) &&
|
||||
(!change || change.endA >= change.endB) &&
|
||||
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "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 && $from.pos < parse.doc.content.size &&
|
||||
(!$from.sameParent($to) || !$from.parent.inlineContent) &&
|
||||
$from.pos < $to.pos && !/\S/.test(parse.doc.textBetween($from.pos, $to.pos, "", "")))) &&
|
||||
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) {
|
||||
view.input.lastIOSEnter = 0
|
||||
return
|
||||
}
|
||||
// Same for backspace
|
||||
if (view.state.selection.anchor > change.start &&
|
||||
looksLikeBackspace(doc, change.start, change.endA, $from, $to) &&
|
||||
view.someProp("handleKeyDown", f => f(view, keyEvent(8, "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(() => {
|
||||
view.someProp("handleKeyDown", function (f) { return f(view, keyEvent(13, "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 looksLikeBackspace(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.isTextblock) {
|
||||
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 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)
|
||||
if (start == null) return null
|
||||
let {a: endA, b: endB} = a.findDiffEnd(b, pos + a.size, pos + b.size)!
|
||||
if (preferredSide == "end") {
|
||||
let adjust = Math.max(0, start - Math.min(endA, endB))
|
||||
preferredPos -= endA + adjust - start
|
||||
}
|
||||
if (endA < start && a.size < b.size) {
|
||||
let move = preferredPos <= start && preferredPos >= endA ? start - preferredPos : 0
|
||||
start -= move
|
||||
if (start && start < b.size && isSurrogatePair(b.textBetween(start - 1, start + 1)))
|
||||
start += move ? 1 : -1
|
||||
endB = start + (endB - endA)
|
||||
endA = start
|
||||
} else if (endB < start) {
|
||||
let move = preferredPos <= start && preferredPos >= endB ? start - preferredPos : 0
|
||||
start -= move
|
||||
if (start && start < a.size && isSurrogatePair(a.textBetween(start - 1, start + 1)))
|
||||
start += move ? 1 : -1
|
||||
endA = start + (endA - endB)
|
||||
endB = start
|
||||
}
|
||||
return {start, endA, endB}
|
||||
}
|
||||
|
||||
function isSurrogatePair(str: string) {
|
||||
if (str.length != 2) return false
|
||||
let a = str.charCodeAt(0), b = str.charCodeAt(1)
|
||||
return a >= 0xDC00 && a <= 0xDFFF && b >= 0xD800 && b <= 0xDBFF
|
||||
}
|
||||
+515
@@ -0,0 +1,515 @@
|
||||
import {EditorState} from "prosemirror-state"
|
||||
import {nodeSize, textRange, parentNode, caretFromPoint} 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) {
|
||||
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 (parent.nodeType != 1) { parent = parentNode(parent); continue }
|
||||
let elt = parent as HTMLElement
|
||||
let atTop = elt == doc.body
|
||||
let bounding = atTop ? windowRect(doc) : clientRect(elt as HTMLElement)
|
||||
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 = elt.scrollLeft, startY = elt.scrollTop
|
||||
if (moveY) elt.scrollTop += moveY
|
||||
if (moveX) elt.scrollLeft += moveX
|
||||
let dX = elt.scrollLeft - startX, dY = elt.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 as HTMLElement).position
|
||||
if (/^(fixed|sticky)$/.test(pos)) break
|
||||
parent = pos == "absolute" ? (parent as HTMLElement).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 as HTMLElement).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, dxClosest = 2e8, coordsClosest: {left: number, top: number} | undefined, offset = 0
|
||||
let rowBot = coords.top, rowTop = coords.top
|
||||
let firstBelow: Node | undefined, coordsBelow: {left: number, top: number} | undefined
|
||||
for (let child = node.firstChild, childIndex = 0; child; child = child.nextSibling, childIndex++) {
|
||||
let rects
|
||||
if (child.nodeType == 1) rects = (child as HTMLElement).getClientRects()
|
||||
else if (child.nodeType == 3) rects = textRange(child as Text).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 && closest.nodeType == 3 ? {
|
||||
left: rect.right < coords.left ? rect.right : rect.left,
|
||||
top: coords.top
|
||||
} : coords
|
||||
if (child.nodeType == 1 && 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 && closest.nodeType == 3) return findOffsetInText(closest as Text, coordsClosest!)
|
||||
if (!closest || (dxClosest && closest.nodeType == 1)) return {node, offset}
|
||||
return findOffsetInNode(closest as HTMLElement, 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 (node.nodeType == 1 && !node.firstChild) {
|
||||
let rect = (node as HTMLElement).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 (desc.dom.nodeType == 1 && (desc.node.isBlock && desc.parent || !desc.contentDOM) &&
|
||||
// Ignore elements with zero-size bounding rectangles
|
||||
((rect = (desc.dom as HTMLElement).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 (child.nodeType == 1) {
|
||||
let rects = (child as HTMLElement).getClientRects()
|
||||
for (let j = 0; j < rects.length; j++) {
|
||||
let rect = rects[j]
|
||||
if (inRect(coords, rect)) return elementFromPoint(child as HTMLElement, 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.nodeType != 1 ? elt.parentNode : 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 && node.nodeType == 1) {
|
||||
// Firefox will sometimes return offsets into <input> 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 && node.nodeType == 1 && (prev = node.childNodes[offset - 1]).nodeType == 1 &&
|
||||
(prev as HTMLElement).contentEditable == "false" && (prev as HTMLElement).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 && node.lastChild!.nodeType == 1 &&
|
||||
coords.top > (node.lastChild as HTMLElement).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 || node.nodeType != 1 || 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: DOMRect) {
|
||||
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 (node.nodeType == 3) {
|
||||
// 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 as Text, 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 as Text, offset - 1, offset - 1), -1)
|
||||
if (rectBefore.top == rect.top) {
|
||||
let rectAfter = singleRect(textRange(node as Text, 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 as Text, 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 (before.nodeType == 1) return flattenH((before as HTMLElement).getBoundingClientRect(), false)
|
||||
}
|
||||
if (atom == null && offset < nodeSize(node)) {
|
||||
let after = node.childNodes[offset]
|
||||
if (after.nodeType == 1) return flattenH((after as HTMLElement).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 = before.nodeType == 3 ? textRange(before as Text, 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
|
||||
: before.nodeType == 1 && (before.nodeName != "BR" || !before.nextSibling) ? before : null
|
||||
if (target) return flattenV(singleRect(target as Range | HTMLElement, 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 : after.nodeType == 3 ? textRange(after as Text, 0, (supportEmptyRange ? 0 : 1))
|
||||
: after.nodeType == 1 ? after : null
|
||||
if (target) return flattenV(singleRect(target as Range | HTMLElement, -1), true)
|
||||
}
|
||||
// All else failed, just try to get a rectangle for the target node
|
||||
return flattenV(singleRect(node.nodeType == 3 ? textRange(node as Text) : 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<T>(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 (child.nodeType == 1) boxes = (child as HTMLElement).getClientRects()
|
||||
else if (child.nodeType == 3) boxes = textRange(child as Text, 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 && !parentDOM.contains(newNode.nodeType == 1 ? newNode : newNode.parentNode) ||
|
||||
(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)
|
||||
}
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
import {Selection} from "prosemirror-state"
|
||||
import * as browser from "./browser"
|
||||
import {domIndex, isEquivalentPosition, selectionCollapsed, parentNode, DOMSelectionRange, DOMNode, DOMSelection} 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<Node> = 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: container!.nodeType == 3 ? 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 sel = view.domSelectionRange()
|
||||
let newSel = !this.suppressingSelectionUpdates && !this.currentSelection.eq(sel) && hasFocusAndSelection(view) && !this.ignoreSelectionChange(sel)
|
||||
|
||||
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)) {
|
||||
// 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
|
||||
if (after && after.nodeType == 1 && (after as HTMLElement).contentEditable == "false")
|
||||
node.parentNode.removeChild(node)
|
||||
}
|
||||
} 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(sel) && (readSel = selectionFromDOM(view)) &&
|
||||
readSel.eq(Selection.near(view.state.doc.resolve(0), 1))) {
|
||||
view.input.lastFocus = 0
|
||||
selectionToDOM(view)
|
||||
this.currentSelection.set(sel)
|
||||
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 (!this.currentSelection.eq(sel)) selectionToDOM(view)
|
||||
this.currentSelection.set(sel)
|
||||
}
|
||||
}
|
||||
|
||||
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 (node.nodeType == 3) this.lastChangedTextNode = node as Text
|
||||
}
|
||||
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<EditorView, null> = 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 || first.nodeType != 1 || (first as HTMLElement).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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+825
@@ -0,0 +1,825 @@
|
||||
import {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} 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://github.com/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<DirectEditorProps>) {
|
||||
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.
|
||||
if (forceSelUpdate ||
|
||||
!(this.input.mouseDown && this.domObserver.currentSelection.eq(this.domSelectionRange()) &&
|
||||
anchorInRightPlace(this))) {
|
||||
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 || !this.dom.contains(startDOM.nodeType == 1 ? startDOM : startDOM.parentNode)) {
|
||||
// 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 (target.nodeType == 1) scrollRectIntoView(this, (target as HTMLElement).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 (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 && 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 extends keyof EditorProps, Result>(
|
||||
propName: PropName,
|
||||
f: (value: NonNullable<EditorProps[PropName]>) => Result
|
||||
): Result | undefined
|
||||
someProp<PropName extends keyof EditorProps>(propName: PropName): NonNullable<EditorProps[PropName]> | undefined
|
||||
someProp<PropName extends keyof EditorProps, Result>(
|
||||
propName: PropName,
|
||||
f?: (value: NonNullable<EditorProps[PropName]>) => 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.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<P = any> {
|
||||
/// 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 `<p>` 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
|
||||
}
|
||||
+830
@@ -0,0 +1,830 @@
|
||||
import {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} 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<string, boolean> = {touchstart: true, touchmove: true}
|
||||
|
||||
export class InputState {
|
||||
shiftKey = false
|
||||
mouseDown: MouseDown | null = null
|
||||
lastKeyCode: number | null = null
|
||||
lastKeyCodeTime = 0
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
// 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) {
|
||||
view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))
|
||||
view.input.lastIOSEnter = 0
|
||||
}
|
||||
}, 200)
|
||||
} else if (view.someProp("handleKeyDown", f => f(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 doc = view.state.doc
|
||||
if (inside == -1) {
|
||||
if (doc.inlineContent) {
|
||||
updateSelection(view, TextSelection.create(doc, 0, doc.content.size), "pointer")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
updateSelection(view, TextSelection.create(doc, nodePos + 1, nodePos + 1 + node.content.size), "pointer")
|
||||
else if (NodeSelection.isSelectable(node))
|
||||
updateSelection(view, NodeSelection.create(doc, nodePos), "pointer")
|
||||
else
|
||||
continue
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
let pos = view.posAtCoords(eventCoords(event))
|
||||
if (!pos) return
|
||||
|
||||
if (type == "singleClick") {
|
||||
if (view.input.mouseDown) view.input.mouseDown.done()
|
||||
view.input.mouseDown = new MouseDown(view, pos, event, !!flushed)
|
||||
} else if ((type == "doubleClick" ? handleDoubleClick : handleTripleClick)(view, pos.pos, pos.inside, event)) {
|
||||
event.preventDefault()
|
||||
} else {
|
||||
setSelectionOrigin(view, "pointer")
|
||||
}
|
||||
}
|
||||
|
||||
class MouseDown {
|
||||
startDoc: Node
|
||||
selectNode: boolean
|
||||
allowDefault: boolean
|
||||
delayedSelectionSync = false
|
||||
mightDrag: {node: Node, pos: number, addAttr: boolean, setUneditable: boolean} | null = null
|
||||
target: HTMLElement | null
|
||||
|
||||
constructor(
|
||||
readonly view: EditorView,
|
||||
readonly pos: {pos: number, inside: number},
|
||||
readonly event: MouseEvent,
|
||||
readonly flushed: boolean
|
||||
) {
|
||||
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 && targetDesc.nodeDOM.nodeType == 1 ? targetDesc.nodeDOM as HTMLElement : 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()
|
||||
}
|
||||
|
||||
view.root.addEventListener("mouseup", this.up = this.up.bind(this) as any)
|
||||
view.root.addEventListener("mousemove", this.move = this.move.bind(this) as any)
|
||||
setSelectionOrigin(view, "pointer")
|
||||
}
|
||||
|
||||
done() {
|
||||
this.view.root.removeEventListener("mouseup", this.up as any)
|
||||
this.view.root.removeEventListener("mousemove", this.move as any)
|
||||
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(() => selectionToDOM(this.view))
|
||||
this.view.input.mouseDown = null
|
||||
}
|
||||
|
||||
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")
|
||||
if (event.buttons == 0) this.done()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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(event.timeStamp - 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.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 && node.nodeType == 1 && offset != 0;) {
|
||||
let before = offset < 0 ? node.lastChild : node.childNodes[offset - 1]
|
||||
if (!before) break
|
||||
if (before.nodeType == 3) {
|
||||
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 || focusNode.nodeType != 1 || focusOffset >= focusNode.childNodes.length) return false
|
||||
let next = focusNode.childNodes[focusOffset]
|
||||
return next.nodeType == 1 && (next as HTMLElement).contentEditable == "false"
|
||||
}
|
||||
|
||||
editHandlers.compositionend = (view, event) => {
|
||||
if (view.composing) {
|
||||
view.input.composing = false
|
||||
view.input.compositionEndedAt = event.timeStamp
|
||||
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 = timestampFromCustomEvent()
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
function timestampFromCustomEvent() {
|
||||
let event = document.createEvent("Event")
|
||||
event.initEvent("event", true, true)
|
||||
return event.timeStamp
|
||||
}
|
||||
|
||||
/// @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 moves = view.someProp("dragCopies", test => !test(event))
|
||||
return moves != null ? moves : !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 && (event.target as HTMLElement).nodeType == 1) {
|
||||
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://github.com/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
|
||||
setTimeout(() => {
|
||||
if (view.docView && view.hasFocus() && !view.domObserver.currentSelection.eq(view.domSelectionRange()))
|
||||
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 Android when after an uneditable node.
|
||||
if (browser.chrome && browser.android && event.inputType == "deleteContentBackward") {
|
||||
view.domObserver.flushSoon()
|
||||
let {domChangeCount} = view.input
|
||||
setTimeout(() => {
|
||||
if (view.input.domChangeCount != domChangeCount) return // Event already had some effect
|
||||
// This bug tends to close the virtual keyboard, so we refocus
|
||||
view.dom.blur()
|
||||
view.focus()
|
||||
if (view.someProp("handleKeyDown", f => f(view, keyEvent(8, "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]
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import {TextSelection, NodeSelection, Selection} from "prosemirror-state"
|
||||
import {ResolvedPos} from "prosemirror-model"
|
||||
|
||||
import * as browser from "./browser"
|
||||
import {isEquivalentPosition, domIndex, isOnEdge, selectionCollapsed} 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
|
||||
let nearestDescNode = (nearestDesc as NodeViewDesc).node
|
||||
if (nearestDesc && nearestDescNode.isAtom && NodeSelection.isSelectable(nearestDescNode) && nearestDesc.parent
|
||||
&& !(nearestDescNode.isInline && isOnEdge(domSel.focusNode, domSel.focusOffset, nearestDesc.dom))) {
|
||||
let pos = nearestDesc.posBefore
|
||||
selection = new NodeSelection(head == pos ? $head : doc.resolve(pos))
|
||||
}
|
||||
} 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
|
||||
|
||||
// The delayed drag selection causes issues with Cell Selections
|
||||
// in Safari. And the drag selection delay is to workarond issues
|
||||
// which only present in Chrome.
|
||||
if (!force && view.input.mouseDown && view.input.mouseDown.allowDefault && browser.chrome) {
|
||||
let domSel = view.domSelectionRange(), curSel = view.domObserver.currentSelection
|
||||
if (domSel.anchorNode && curSel.anchorNode &&
|
||||
isEquivalentPosition(domSel.anchorNode, domSel.anchorOffset,
|
||||
curSel.anchorNode, curSel.anchorOffset)) {
|
||||
view.input.mouseDown.delayedSelectionSync = true
|
||||
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 view.dom.contains(sel.anchorNode.nodeType == 3 ? sel.anchorNode.parentNode : sel.anchorNode) &&
|
||||
(view.editable || view.dom.contains(sel.focusNode!.nodeType == 3 ? sel.focusNode!.parentNode : 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)
|
||||
}
|
||||
+1590
File diff suppressed because it is too large
Load Diff
+54
@@ -0,0 +1,54 @@
|
||||
.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://github.com/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;
|
||||
}
|
||||
Reference in New Issue
Block a user