This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
This module defines ProseMirror's content model, the data structures
used to represent and work with documents.
### Document Structure
A ProseMirror document is a tree. At each level, a [node](#model.Node)
describes the type of the content, and holds a
[fragment](#model.Fragment) containing its children.
@Node
@Fragment
@Mark
@Slice
@Attrs
@ReplaceError
### Resolved Positions
Positions in a document can be represented as integer
[offsets](/docs/guide/#doc.indexing). But you'll often want to use a
more convenient representation.
@ResolvedPos
@NodeRange
### Document Schema
Every ProseMirror document conforms to a
[schema](/docs/guide/#schema), which describes the set of nodes and
marks that it is made out of, along with the relations between those,
such as which node may occur as a child node of which other nodes.
@Schema
@SchemaSpec
@NodeSpec
@MarkSpec
@AttributeSpec
@NodeType
@MarkType
@ContentMatch
### DOM Representation
Because representing a document as a tree of DOM nodes is central to
the way ProseMirror operates, DOM [parsing](#model.DOMParser) and
[serializing](#model.DOMSerializer) is integrated with the model.
(But note that you do _not_ need to have a DOM implementation loaded
to use this module.)
@DOMParser
@ParseOptions
@GenericParseRule
@TagParseRule
@StyleParseRule
@ParseRule
@DOMSerializer
@DOMOutputSpec

View File

@@ -0,0 +1,15 @@
export function compareDeep(a: any, b: any) {
if (a === b) return true
if (!(a && typeof a == "object") ||
!(b && typeof b == "object")) return false
let array = Array.isArray(a)
if (Array.isArray(b) != array) return false
if (array) {
if (a.length != b.length) return false
for (let i = 0; i < a.length; i++) if (!compareDeep(a[i], b[i])) return false
} else {
for (let p in a) if (!(p in b) || !compareDeep(a[p], b[p])) return false
for (let p in b) if (!(p in a)) return false
}
return true
}

View File

@@ -0,0 +1,413 @@
import {Fragment} from "./fragment"
import {NodeType} from "./schema"
type MatchEdge = {type: NodeType, next: ContentMatch}
/// Instances of this class represent a match state of a node type's
/// [content expression](#model.NodeSpec.content), and can be used to
/// find out whether further content matches here, and whether a given
/// position is a valid end of the node.
export class ContentMatch {
/// @internal
readonly next: MatchEdge[] = []
/// @internal
readonly wrapCache: (NodeType | readonly NodeType[] | null)[] = []
/// @internal
constructor(
/// True when this match state represents a valid end of the node.
readonly validEnd: boolean
) {}
/// @internal
static parse(string: string, nodeTypes: {readonly [name: string]: NodeType}): ContentMatch {
let stream = new TokenStream(string, nodeTypes)
if (stream.next == null) return ContentMatch.empty
let expr = parseExpr(stream)
if (stream.next) stream.err("Unexpected trailing text")
let match = dfa(nfa(expr))
checkForDeadEnds(match, stream)
return match
}
/// Match a node type, returning a match after that node if
/// successful.
matchType(type: NodeType): ContentMatch | null {
for (let i = 0; i < this.next.length; i++)
if (this.next[i].type == type) return this.next[i].next
return null
}
/// Try to match a fragment. Returns the resulting match when
/// successful.
matchFragment(frag: Fragment, start = 0, end = frag.childCount): ContentMatch | null {
let cur: ContentMatch | null = this
for (let i = start; cur && i < end; i++)
cur = cur.matchType(frag.child(i).type)
return cur
}
/// @internal
get inlineContent() {
return this.next.length != 0 && this.next[0].type.isInline
}
/// Get the first matching node type at this match position that can
/// be generated.
get defaultType(): NodeType | null {
for (let i = 0; i < this.next.length; i++) {
let {type} = this.next[i]
if (!(type.isText || type.hasRequiredAttrs())) return type
}
return null
}
/// @internal
compatible(other: ContentMatch) {
for (let i = 0; i < this.next.length; i++)
for (let j = 0; j < other.next.length; j++)
if (this.next[i].type == other.next[j].type) return true
return false
}
/// Try to match the given fragment, and if that fails, see if it can
/// be made to match by inserting nodes in front of it. When
/// successful, return a fragment of inserted nodes (which may be
/// empty if nothing had to be inserted). When `toEnd` is true, only
/// return a fragment if the resulting match goes to the end of the
/// content expression.
fillBefore(after: Fragment, toEnd = false, startIndex = 0): Fragment | null {
let seen: ContentMatch[] = [this]
function search(match: ContentMatch, types: readonly NodeType[]): Fragment | null {
let finished = match.matchFragment(after, startIndex)
if (finished && (!toEnd || finished.validEnd))
return Fragment.from(types.map(tp => tp.createAndFill()!))
for (let i = 0; i < match.next.length; i++) {
let {type, next} = match.next[i]
if (!(type.isText || type.hasRequiredAttrs()) && seen.indexOf(next) == -1) {
seen.push(next)
let found = search(next, types.concat(type))
if (found) return found
}
}
return null
}
return search(this, [])
}
/// Find a set of wrapping node types that would allow a node of the
/// given type to appear at this position. The result may be empty
/// (when it fits directly) and will be null when no such wrapping
/// exists.
findWrapping(target: NodeType): readonly NodeType[] | null {
for (let i = 0; i < this.wrapCache.length; i += 2)
if (this.wrapCache[i] == target) return this.wrapCache[i + 1] as (readonly NodeType[] | null)
let computed = this.computeWrapping(target)
this.wrapCache.push(target, computed)
return computed
}
/// @internal
computeWrapping(target: NodeType): readonly NodeType[] | null {
type Active = {match: ContentMatch, type: NodeType | null, via: Active | null}
let seen = Object.create(null), active: Active[] = [{match: this, type: null, via: null}]
while (active.length) {
let current = active.shift()!, match = current.match
if (match.matchType(target)) {
let result: NodeType[] = []
for (let obj: Active = current; obj.type; obj = obj.via!)
result.push(obj.type)
return result.reverse()
}
for (let i = 0; i < match.next.length; i++) {
let {type, next} = match.next[i]
if (!type.isLeaf && !type.hasRequiredAttrs() && !(type.name in seen) && (!current.type || next.validEnd)) {
active.push({match: type.contentMatch, type, via: current})
seen[type.name] = true
}
}
}
return null
}
/// The number of outgoing edges this node has in the finite
/// automaton that describes the content expression.
get edgeCount() {
return this.next.length
}
/// Get the _n_th outgoing edge from this node in the finite
/// automaton that describes the content expression.
edge(n: number): MatchEdge {
if (n >= this.next.length) throw new RangeError(`There's no ${n}th edge in this content match`)
return this.next[n]
}
/// @internal
toString() {
let seen: ContentMatch[] = []
function scan(m: ContentMatch) {
seen.push(m)
for (let i = 0; i < m.next.length; i++)
if (seen.indexOf(m.next[i].next) == -1) scan(m.next[i].next)
}
scan(this)
return seen.map((m, i) => {
let out = i + (m.validEnd ? "*" : " ") + " "
for (let i = 0; i < m.next.length; i++)
out += (i ? ", " : "") + m.next[i].type.name + "->" + seen.indexOf(m.next[i].next)
return out
}).join("\n")
}
/// @internal
static empty = new ContentMatch(true)
}
class TokenStream {
inline: boolean | null = null
pos = 0
tokens: string[]
constructor(
readonly string: string,
readonly nodeTypes: {readonly [name: string]: NodeType}
) {
this.tokens = string.split(/\s*(?=\b|\W|$)/)
if (this.tokens[this.tokens.length - 1] == "") this.tokens.pop()
if (this.tokens[0] == "") this.tokens.shift()
}
get next() { return this.tokens[this.pos] }
eat(tok: string) { return this.next == tok && (this.pos++ || true) }
err(str: string): never { throw new SyntaxError(str + " (in content expression '" + this.string + "')") }
}
type Expr =
{type: "choice", exprs: Expr[]} |
{type: "seq", exprs: Expr[]} |
{type: "plus", expr: Expr} |
{type: "star", expr: Expr} |
{type: "opt", expr: Expr} |
{type: "range", min: number, max: number, expr: Expr} |
{type: "name", value: NodeType}
function parseExpr(stream: TokenStream): Expr {
let exprs = []
do { exprs.push(parseExprSeq(stream)) }
while (stream.eat("|"))
return exprs.length == 1 ? exprs[0] : {type: "choice", exprs}
}
function parseExprSeq(stream: TokenStream): Expr {
let exprs = []
do { exprs.push(parseExprSubscript(stream)) }
while (stream.next && stream.next != ")" && stream.next != "|")
return exprs.length == 1 ? exprs[0] : {type: "seq", exprs}
}
function parseExprSubscript(stream: TokenStream): Expr {
let expr = parseExprAtom(stream)
for (;;) {
if (stream.eat("+"))
expr = {type: "plus", expr}
else if (stream.eat("*"))
expr = {type: "star", expr}
else if (stream.eat("?"))
expr = {type: "opt", expr}
else if (stream.eat("{"))
expr = parseExprRange(stream, expr)
else break
}
return expr
}
function parseNum(stream: TokenStream) {
if (/\D/.test(stream.next)) stream.err("Expected number, got '" + stream.next + "'")
let result = Number(stream.next)
stream.pos++
return result
}
function parseExprRange(stream: TokenStream, expr: Expr): Expr {
let min = parseNum(stream), max = min
if (stream.eat(",")) {
if (stream.next != "}") max = parseNum(stream)
else max = -1
}
if (!stream.eat("}")) stream.err("Unclosed braced range")
return {type: "range", min, max, expr}
}
function resolveName(stream: TokenStream, name: string): readonly NodeType[] {
let types = stream.nodeTypes, type = types[name]
if (type) return [type]
let result = []
for (let typeName in types) {
let type = types[typeName]
if (type.groups.indexOf(name) > -1) result.push(type)
}
if (result.length == 0) stream.err("No node type or group '" + name + "' found")
return result
}
function parseExprAtom(stream: TokenStream): Expr {
if (stream.eat("(")) {
let expr = parseExpr(stream)
if (!stream.eat(")")) stream.err("Missing closing paren")
return expr
} else if (!/\W/.test(stream.next)) {
let exprs = resolveName(stream, stream.next).map(type => {
if (stream.inline == null) stream.inline = type.isInline
else if (stream.inline != type.isInline) stream.err("Mixing inline and block content")
return {type: "name", value: type} as Expr
})
stream.pos++
return exprs.length == 1 ? exprs[0] : {type: "choice", exprs}
} else {
stream.err("Unexpected token '" + stream.next + "'")
}
}
// The code below helps compile a regular-expression-like language
// into a deterministic finite automaton. For a good introduction to
// these concepts, see https://swtch.com/~rsc/regexp/regexp1.html
type Edge = {term: NodeType | undefined, to: number | undefined}
/// Construct an NFA from an expression as returned by the parser. The
/// NFA is represented as an array of states, which are themselves
/// arrays of edges, which are `{term, to}` objects. The first state is
/// the entry state and the last node is the success state.
///
/// Note that unlike typical NFAs, the edge ordering in this one is
/// significant, in that it is used to contruct filler content when
/// necessary.
function nfa(expr: Expr): Edge[][] {
let nfa: Edge[][] = [[]]
connect(compile(expr, 0), node())
return nfa
function node() { return nfa.push([]) - 1 }
function edge(from: number, to?: number, term?: NodeType) {
let edge = {term, to}
nfa[from].push(edge)
return edge
}
function connect(edges: Edge[], to: number) {
edges.forEach(edge => edge.to = to)
}
function compile(expr: Expr, from: number): Edge[] {
if (expr.type == "choice") {
return expr.exprs.reduce((out, expr) => out.concat(compile(expr, from)), [] as Edge[])
} else if (expr.type == "seq") {
for (let i = 0;; i++) {
let next = compile(expr.exprs[i], from)
if (i == expr.exprs.length - 1) return next
connect(next, from = node())
}
} else if (expr.type == "star") {
let loop = node()
edge(from, loop)
connect(compile(expr.expr, loop), loop)
return [edge(loop)]
} else if (expr.type == "plus") {
let loop = node()
connect(compile(expr.expr, from), loop)
connect(compile(expr.expr, loop), loop)
return [edge(loop)]
} else if (expr.type == "opt") {
return [edge(from)].concat(compile(expr.expr, from))
} else if (expr.type == "range") {
let cur = from
for (let i = 0; i < expr.min; i++) {
let next = node()
connect(compile(expr.expr, cur), next)
cur = next
}
if (expr.max == -1) {
connect(compile(expr.expr, cur), cur)
} else {
for (let i = expr.min; i < expr.max; i++) {
let next = node()
edge(cur, next)
connect(compile(expr.expr, cur), next)
cur = next
}
}
return [edge(cur)]
} else if (expr.type == "name") {
return [edge(from, undefined, expr.value)]
} else {
throw new Error("Unknown expr type")
}
}
}
function cmp(a: number, b: number) { return b - a }
// Get the set of nodes reachable by null edges from `node`. Omit
// nodes with only a single null-out-edge, since they may lead to
// needless duplicated nodes.
function nullFrom(nfa: Edge[][], node: number): readonly number[] {
let result: number[] = []
scan(node)
return result.sort(cmp)
function scan(node: number): void {
let edges = nfa[node]
if (edges.length == 1 && !edges[0].term) return scan(edges[0].to!)
result.push(node)
for (let i = 0; i < edges.length; i++) {
let {term, to} = edges[i]
if (!term && result.indexOf(to!) == -1) scan(to!)
}
}
}
// Compiles an NFA as produced by `nfa` into a DFA, modeled as a set
// of state objects (`ContentMatch` instances) with transitions
// between them.
function dfa(nfa: Edge[][]): ContentMatch {
let labeled = Object.create(null)
return explore(nullFrom(nfa, 0))
function explore(states: readonly number[]) {
let out: [NodeType, number[]][] = []
states.forEach(node => {
nfa[node].forEach(({term, to}) => {
if (!term) return
let set: number[] | undefined
for (let i = 0; i < out.length; i++) if (out[i][0] == term) set = out[i][1]
nullFrom(nfa, to!).forEach(node => {
if (!set) out.push([term, set = []])
if (set.indexOf(node) == -1) set.push(node)
})
})
})
let state = labeled[states.join(",")] = new ContentMatch(states.indexOf(nfa.length - 1) > -1)
for (let i = 0; i < out.length; i++) {
let states = out[i][1].sort(cmp)
state.next.push({type: out[i][0], next: labeled[states.join(",")] || explore(states)})
}
return state
}
}
function checkForDeadEnds(match: ContentMatch, stream: TokenStream) {
for (let i = 0, work = [match]; i < work.length; i++) {
let state = work[i], dead = !state.validEnd, nodes = []
for (let j = 0; j < state.next.length; j++) {
let {type, next} = state.next[j]
nodes.push(type.name)
if (dead && !(type.isText || type.hasRequiredAttrs())) dead = false
if (work.indexOf(next) == -1) work.push(next)
}
if (dead) stream.err("Only non-generatable nodes (" + nodes.join(", ") + ") in a required position (see https://prosemirror.net/docs/guide/#generatable)")
}
}

View File

@@ -0,0 +1,52 @@
import {Fragment} from "./fragment"
export function findDiffStart(a: Fragment, b: Fragment, pos: number): number | null {
for (let i = 0;; i++) {
if (i == a.childCount || i == b.childCount)
return a.childCount == b.childCount ? null : pos
let childA = a.child(i), childB = b.child(i)
if (childA == childB) { pos += childA.nodeSize; continue }
if (!childA.sameMarkup(childB)) return pos
if (childA.isText && childA.text != childB.text) {
for (let j = 0; childA.text![j] == childB.text![j]; j++)
pos++
return pos
}
if (childA.content.size || childB.content.size) {
let inner = findDiffStart(childA.content, childB.content, pos + 1)
if (inner != null) return inner
}
pos += childA.nodeSize
}
}
export function findDiffEnd(a: Fragment, b: Fragment, posA: number, posB: number): {a: number, b: number} | null {
for (let iA = a.childCount, iB = b.childCount;;) {
if (iA == 0 || iB == 0)
return iA == iB ? null : {a: posA, b: posB}
let childA = a.child(--iA), childB = b.child(--iB), size = childA.nodeSize
if (childA == childB) {
posA -= size; posB -= size
continue
}
if (!childA.sameMarkup(childB)) return {a: posA, b: posB}
if (childA.isText && childA.text != childB.text) {
let same = 0, minSize = Math.min(childA.text!.length, childB.text!.length)
while (same < minSize && childA.text![childA.text!.length - same - 1] == childB.text![childB.text!.length - same - 1]) {
same++; posA--; posB--
}
return {a: posA, b: posB}
}
if (childA.content.size || childB.content.size) {
let inner = findDiffEnd(childA.content, childB.content, posA - 1, posB - 1)
if (inner) return inner
}
posA -= size; posB -= size
}
}

View File

@@ -0,0 +1 @@
export type DOMNode = InstanceType<typeof window.Node>

View File

@@ -0,0 +1,268 @@
import {findDiffStart, findDiffEnd} from "./diff"
import {Node, TextNode} from "./node"
import {Schema} from "./schema"
/// A fragment represents a node's collection of child nodes.
///
/// Like nodes, fragments are persistent data structures, and you
/// should not mutate them or their content. Rather, you create new
/// instances whenever needed. The API tries to make this easy.
export class Fragment {
/// The size of the fragment, which is the total of the size of
/// its content nodes.
readonly size: number
/// @internal
constructor(
/// @internal
readonly content: readonly Node[],
size?: number
) {
this.size = size || 0
if (size == null) for (let i = 0; i < content.length; i++)
this.size += content[i].nodeSize
}
/// Invoke a callback for all descendant nodes between the given two
/// positions (relative to start of this fragment). Doesn't descend
/// into a node when the callback returns `false`.
nodesBetween(from: number, to: number,
f: (node: Node, start: number, parent: Node | null, index: number) => boolean | void,
nodeStart = 0,
parent?: Node) {
for (let i = 0, pos = 0; pos < to; i++) {
let child = this.content[i], end = pos + child.nodeSize
if (end > from && f(child, nodeStart + pos, parent || null, i) !== false && child.content.size) {
let start = pos + 1
child.nodesBetween(Math.max(0, from - start),
Math.min(child.content.size, to - start),
f, nodeStart + start)
}
pos = end
}
}
/// Call the given callback for every descendant node. `pos` will be
/// relative to the start of the fragment. The callback may return
/// `false` to prevent traversal of a given node's children.
descendants(f: (node: Node, pos: number, parent: Node | null, index: number) => boolean | void) {
this.nodesBetween(0, this.size, f)
}
/// Extract the text between `from` and `to`. See the same method on
/// [`Node`](#model.Node.textBetween).
textBetween(from: number, to: number, blockSeparator?: string | null, leafText?: string | null | ((leafNode: Node) => string)) {
let text = "", first = true
this.nodesBetween(from, to, (node, pos) => {
let nodeText = node.isText ? node.text!.slice(Math.max(from, pos) - pos, to - pos)
: !node.isLeaf ? ""
: leafText ? (typeof leafText === "function" ? leafText(node) : leafText)
: node.type.spec.leafText ? node.type.spec.leafText(node)
: ""
if (node.isBlock && (node.isLeaf && nodeText || node.isTextblock) && blockSeparator) {
if (first) first = false
else text += blockSeparator
}
text += nodeText
}, 0)
return text
}
/// Create a new fragment containing the combined content of this
/// fragment and the other.
append(other: Fragment) {
if (!other.size) return this
if (!this.size) return other
let last = this.lastChild!, first = other.firstChild!, content = this.content.slice(), i = 0
if (last.isText && last.sameMarkup(first)) {
content[content.length - 1] = (last as TextNode).withText(last.text! + first.text!)
i = 1
}
for (; i < other.content.length; i++) content.push(other.content[i])
return new Fragment(content, this.size + other.size)
}
/// Cut out the sub-fragment between the two given positions.
cut(from: number, to = this.size) {
if (from == 0 && to == this.size) return this
let result = [], size = 0
if (to > from) for (let i = 0, pos = 0; pos < to; i++) {
let child = this.content[i], end = pos + child.nodeSize
if (end > from) {
if (pos < from || end > to) {
if (child.isText)
child = child.cut(Math.max(0, from - pos), Math.min(child.text!.length, to - pos))
else
child = child.cut(Math.max(0, from - pos - 1), Math.min(child.content.size, to - pos - 1))
}
result.push(child)
size += child.nodeSize
}
pos = end
}
return new Fragment(result, size)
}
/// @internal
cutByIndex(from: number, to: number) {
if (from == to) return Fragment.empty
if (from == 0 && to == this.content.length) return this
return new Fragment(this.content.slice(from, to))
}
/// Create a new fragment in which the node at the given index is
/// replaced by the given node.
replaceChild(index: number, node: Node) {
let current = this.content[index]
if (current == node) return this
let copy = this.content.slice()
let size = this.size + node.nodeSize - current.nodeSize
copy[index] = node
return new Fragment(copy, size)
}
/// Create a new fragment by prepending the given node to this
/// fragment.
addToStart(node: Node) {
return new Fragment([node].concat(this.content), this.size + node.nodeSize)
}
/// Create a new fragment by appending the given node to this
/// fragment.
addToEnd(node: Node) {
return new Fragment(this.content.concat(node), this.size + node.nodeSize)
}
/// Compare this fragment to another one.
eq(other: Fragment): boolean {
if (this.content.length != other.content.length) return false
for (let i = 0; i < this.content.length; i++)
if (!this.content[i].eq(other.content[i])) return false
return true
}
/// The first child of the fragment, or `null` if it is empty.
get firstChild(): Node | null { return this.content.length ? this.content[0] : null }
/// The last child of the fragment, or `null` if it is empty.
get lastChild(): Node | null { return this.content.length ? this.content[this.content.length - 1] : null }
/// The number of child nodes in this fragment.
get childCount() { return this.content.length }
/// Get the child node at the given index. Raise an error when the
/// index is out of range.
child(index: number) {
let found = this.content[index]
if (!found) throw new RangeError("Index " + index + " out of range for " + this)
return found
}
/// Get the child node at the given index, if it exists.
maybeChild(index: number): Node | null {
return this.content[index] || null
}
/// Call `f` for every child node, passing the node, its offset
/// into this parent node, and its index.
forEach(f: (node: Node, offset: number, index: number) => void) {
for (let i = 0, p = 0; i < this.content.length; i++) {
let child = this.content[i]
f(child, p, i)
p += child.nodeSize
}
}
/// Find the first position at which this fragment and another
/// fragment differ, or `null` if they are the same.
findDiffStart(other: Fragment, pos = 0) {
return findDiffStart(this, other, pos)
}
/// Find the first position, searching from the end, at which this
/// fragment and the given fragment differ, or `null` if they are
/// the same. Since this position will not be the same in both
/// nodes, an object with two separate positions is returned.
findDiffEnd(other: Fragment, pos = this.size, otherPos = other.size) {
return findDiffEnd(this, other, pos, otherPos)
}
/// Find the index and inner offset corresponding to a given relative
/// position in this fragment. The result object will be reused
/// (overwritten) the next time the function is called. (Not public.)
findIndex(pos: number, round = -1): {index: number, offset: number} {
if (pos == 0) return retIndex(0, pos)
if (pos == this.size) return retIndex(this.content.length, pos)
if (pos > this.size || pos < 0) throw new RangeError(`Position ${pos} outside of fragment (${this})`)
for (let i = 0, curPos = 0;; i++) {
let cur = this.child(i), end = curPos + cur.nodeSize
if (end >= pos) {
if (end == pos || round > 0) return retIndex(i + 1, end)
return retIndex(i, curPos)
}
curPos = end
}
}
/// Return a debugging string that describes this fragment.
toString(): string { return "<" + this.toStringInner() + ">" }
/// @internal
toStringInner() { return this.content.join(", ") }
/// Create a JSON-serializeable representation of this fragment.
toJSON(): any {
return this.content.length ? this.content.map(n => n.toJSON()) : null
}
/// Deserialize a fragment from its JSON representation.
static fromJSON(schema: Schema, value: any) {
if (!value) return Fragment.empty
if (!Array.isArray(value)) throw new RangeError("Invalid input for Fragment.fromJSON")
return new Fragment(value.map(schema.nodeFromJSON))
}
/// Build a fragment from an array of nodes. Ensures that adjacent
/// text nodes with the same marks are joined together.
static fromArray(array: readonly Node[]) {
if (!array.length) return Fragment.empty
let joined: Node[] | undefined, size = 0
for (let i = 0; i < array.length; i++) {
let node = array[i]
size += node.nodeSize
if (i && node.isText && array[i - 1].sameMarkup(node)) {
if (!joined) joined = array.slice(0, i)
joined[joined.length - 1] = (node as TextNode)
.withText((joined[joined.length - 1] as TextNode).text + (node as TextNode).text)
} else if (joined) {
joined.push(node)
}
}
return new Fragment(joined || array, size)
}
/// Create a fragment from something that can be interpreted as a
/// set of nodes. For `null`, it returns the empty fragment. For a
/// fragment, the fragment itself. For a node or array of nodes, a
/// fragment containing those nodes.
static from(nodes?: Fragment | Node | readonly Node[] | null) {
if (!nodes) return Fragment.empty
if (nodes instanceof Fragment) return nodes
if (Array.isArray(nodes)) return this.fromArray(nodes)
if ((nodes as Node).attrs) return new Fragment([nodes as Node], (nodes as Node).nodeSize)
throw new RangeError("Can not convert " + nodes + " to a Fragment" +
((nodes as any).nodesBetween ? " (looks like multiple versions of prosemirror-model were loaded)" : ""))
}
/// An empty fragment. Intended to be reused whenever a node doesn't
/// contain anything (rather than allocating a new empty fragment for
/// each leaf node).
static empty: Fragment = new Fragment([], 0)
}
const found = {index: 0, offset: 0}
function retIndex(index: number, offset: number) {
found.index = index
found.offset = offset
return found
}

View File

@@ -0,0 +1,871 @@
import {Fragment} from "./fragment"
import {Slice} from "./replace"
import {Mark} from "./mark"
import {Node, TextNode} from "./node"
import {ContentMatch} from "./content"
import {ResolvedPos} from "./resolvedpos"
import {Schema, Attrs, NodeType, MarkType} from "./schema"
import {DOMNode} from "./dom"
/// These are the options recognized by the
/// [`parse`](#model.DOMParser.parse) and
/// [`parseSlice`](#model.DOMParser.parseSlice) methods.
export interface ParseOptions {
/// By default, whitespace is collapsed as per HTML's rules. Pass
/// `true` to preserve whitespace, but normalize newlines to
/// spaces, and `"full"` to preserve whitespace entirely.
preserveWhitespace?: boolean | "full"
/// When given, the parser will, beside parsing the content,
/// record the document positions of the given DOM positions. It
/// will do so by writing to the objects, adding a `pos` property
/// that holds the document position. DOM positions that are not
/// in the parsed content will not be written to.
findPositions?: {node: DOMNode, offset: number, pos?: number}[]
/// The child node index to start parsing from.
from?: number
/// The child node index to stop parsing at.
to?: number
/// By default, the content is parsed into the schema's default
/// [top node type](#model.Schema.topNodeType). You can pass this
/// option to use the type and attributes from a different node
/// as the top container.
topNode?: Node
/// Provide the starting content match that content parsed into the
/// top node is matched against.
topMatch?: ContentMatch
/// A set of additional nodes to count as
/// [context](#model.ParseRule.context) when parsing, above the
/// given [top node](#model.ParseOptions.topNode).
context?: ResolvedPos
/// @internal
ruleFromNode?: (node: DOMNode) => Omit<TagParseRule, "tag"> | null
/// @internal
topOpen?: boolean
}
/// Fields that may be present in both [tag](#model.TagParseRule) and
/// [style](#model.StyleParseRule) parse rules.
export interface GenericParseRule {
/// Can be used to change the order in which the parse rules in a
/// schema are tried. Those with higher priority come first. Rules
/// without a priority are counted as having priority 50. This
/// property is only meaningful in a schema—when directly
/// constructing a parser, the order of the rule array is used.
priority?: number
/// By default, when a rule matches an element or style, no further
/// rules get a chance to match it. By setting this to `false`, you
/// indicate that even when this rule matches, other rules that come
/// after it should also run.
consuming?: boolean
/// When given, restricts this rule to only match when the current
/// context—the parent nodes into which the content is being
/// parsed—matches this expression. Should contain one or more node
/// names or node group names followed by single or double slashes.
/// For example `"paragraph/"` means the rule only matches when the
/// parent node is a paragraph, `"blockquote/paragraph/"` restricts
/// it to be in a paragraph that is inside a blockquote, and
/// `"section//"` matches any position inside a section—a double
/// slash matches any sequence of ancestor nodes. To allow multiple
/// different contexts, they can be separated by a pipe (`|`)
/// character, as in `"blockquote/|list_item/"`.
context?: string
/// The name of the mark type to wrap the matched content in.
mark?: string
/// When true, ignore content that matches this rule.
ignore?: boolean
/// When true, finding an element that matches this rule will close
/// the current node.
closeParent?: boolean
/// When true, ignore the node that matches this rule, but do parse
/// its content.
skip?: boolean
/// Attributes for the node or mark created by this rule. When
/// `getAttrs` is provided, it takes precedence.
attrs?: Attrs
}
/// Parse rule targeting a DOM element.
export interface TagParseRule extends GenericParseRule {
/// A CSS selector describing the kind of DOM elements to match.
tag: string
/// The namespace to match. Nodes are only matched when the
/// namespace matches or this property is null.
namespace?: string
/// The name of the node type to create when this rule matches. Each
/// rule should have either a `node`, `mark`, or `ignore` property
/// (except when it appears in a [node](#model.NodeSpec.parseDOM) or
/// [mark spec](#model.MarkSpec.parseDOM), in which case the `node`
/// or `mark` property will be derived from its position).
node?: string
/// A function used to compute the attributes for the node or mark
/// created by this rule. Can also be used to describe further
/// conditions the DOM element or style must match. When it returns
/// `false`, the rule won't match. When it returns null or undefined,
/// that is interpreted as an empty/default set of attributes.
getAttrs?: (node: HTMLElement) => Attrs | false | null
/// For rules that produce non-leaf nodes, by default the content of
/// the DOM element is parsed as content of the node. If the child
/// nodes are in a descendent node, this may be a CSS selector
/// string that the parser must use to find the actual content
/// element, or a function that returns the actual content element
/// to the parser.
contentElement?: string | HTMLElement | ((node: DOMNode) => HTMLElement)
/// Can be used to override the content of a matched node. When
/// present, instead of parsing the node's child nodes, the result of
/// this function is used.
getContent?: (node: DOMNode, schema: Schema) => Fragment
/// Controls whether whitespace should be preserved when parsing the
/// content inside the matched element. `false` means whitespace may
/// be collapsed, `true` means that whitespace should be preserved
/// but newlines normalized to spaces, and `"full"` means that
/// newlines should also be preserved.
preserveWhitespace?: boolean | "full"
}
/// A parse rule targeting a style property.
export interface StyleParseRule extends GenericParseRule {
/// A CSS property name to match. This rule will match inline styles
/// that list that property. May also have the form
/// `"property=value"`, in which case the rule only matches if the
/// property's value exactly matches the given value. (For more
/// complicated filters, use [`getAttrs`](#model.ParseRule.getAttrs)
/// and return false to indicate that the match failed.) Rules
/// matching styles may only produce [marks](#model.ParseRule.mark),
/// not nodes.
style: string
/// Given to make TS see ParseRule as a tagged union @hide
tag?: undefined
/// Style rules can remove marks from the set of active marks.
clearMark?: (mark: Mark) => boolean
/// A function used to compute the attributes for the node or mark
/// created by this rule. Called with the style's value.
getAttrs?: (node: string) => Attrs | false | null
}
/// A value that describes how to parse a given DOM node or inline
/// style as a ProseMirror node or mark.
export type ParseRule = TagParseRule | StyleParseRule
function isTagRule(rule: ParseRule): rule is TagParseRule { return (rule as TagParseRule).tag != null }
function isStyleRule(rule: ParseRule): rule is StyleParseRule { return (rule as StyleParseRule).style != null }
/// A DOM parser represents a strategy for parsing DOM content into a
/// ProseMirror document conforming to a given schema. Its behavior is
/// defined by an array of [rules](#model.ParseRule).
export class DOMParser {
/// @internal
tags: TagParseRule[] = []
/// @internal
styles: StyleParseRule[] = []
/// @internal
normalizeLists: boolean
/// Create a parser that targets the given schema, using the given
/// parsing rules.
constructor(
/// The schema into which the parser parses.
readonly schema: Schema,
/// The set of [parse rules](#model.ParseRule) that the parser
/// uses, in order of precedence.
readonly rules: readonly ParseRule[]
) {
rules.forEach(rule => {
if (isTagRule(rule)) this.tags.push(rule)
else if (isStyleRule(rule)) this.styles.push(rule)
})
// Only normalize list elements when lists in the schema can't directly contain themselves
this.normalizeLists = !this.tags.some(r => {
if (!/^(ul|ol)\b/.test(r.tag!) || !r.node) return false
let node = schema.nodes[r.node]
return node.contentMatch.matchType(node)
})
}
/// Parse a document from the content of a DOM node.
parse(dom: DOMNode, options: ParseOptions = {}): Node {
let context = new ParseContext(this, options, false)
context.addAll(dom, options.from, options.to)
return context.finish() as Node
}
/// Parses the content of the given DOM node, like
/// [`parse`](#model.DOMParser.parse), and takes the same set of
/// options. But unlike that method, which produces a whole node,
/// this one returns a slice that is open at the sides, meaning that
/// the schema constraints aren't applied to the start of nodes to
/// the left of the input and the end of nodes at the end.
parseSlice(dom: DOMNode, options: ParseOptions = {}) {
let context = new ParseContext(this, options, true)
context.addAll(dom, options.from, options.to)
return Slice.maxOpen(context.finish() as Fragment)
}
/// @internal
matchTag(dom: DOMNode, context: ParseContext, after?: TagParseRule) {
for (let i = after ? this.tags.indexOf(after) + 1 : 0; i < this.tags.length; i++) {
let rule = this.tags[i]
if (matches(dom, rule.tag!) &&
(rule.namespace === undefined || (dom as HTMLElement).namespaceURI == rule.namespace) &&
(!rule.context || context.matchesContext(rule.context))) {
if (rule.getAttrs) {
let result = rule.getAttrs(dom as HTMLElement)
if (result === false) continue
rule.attrs = result || undefined
}
return rule
}
}
}
/// @internal
matchStyle(prop: string, value: string, context: ParseContext, after?: StyleParseRule) {
for (let i = after ? this.styles.indexOf(after) + 1 : 0; i < this.styles.length; i++) {
let rule = this.styles[i], style = rule.style!
if (style.indexOf(prop) != 0 ||
rule.context && !context.matchesContext(rule.context) ||
// Test that the style string either precisely matches the prop,
// or has an '=' sign after the prop, followed by the given
// value.
style.length > prop.length &&
(style.charCodeAt(prop.length) != 61 || style.slice(prop.length + 1) != value))
continue
if (rule.getAttrs) {
let result = rule.getAttrs(value)
if (result === false) continue
rule.attrs = result || undefined
}
return rule
}
}
/// @internal
static schemaRules(schema: Schema) {
let result: ParseRule[] = []
function insert(rule: ParseRule) {
let priority = rule.priority == null ? 50 : rule.priority, i = 0
for (; i < result.length; i++) {
let next = result[i], nextPriority = next.priority == null ? 50 : next.priority
if (nextPriority < priority) break
}
result.splice(i, 0, rule)
}
for (let name in schema.marks) {
let rules = schema.marks[name].spec.parseDOM
if (rules) rules.forEach(rule => {
insert(rule = copy(rule) as ParseRule)
if (!(rule.mark || rule.ignore || (rule as StyleParseRule).clearMark))
rule.mark = name
})
}
for (let name in schema.nodes) {
let rules = schema.nodes[name].spec.parseDOM
if (rules) rules.forEach(rule => {
insert(rule = copy(rule) as TagParseRule)
if (!((rule as TagParseRule).node || rule.ignore || rule.mark))
rule.node = name
})
}
return result
}
/// Construct a DOM parser using the parsing rules listed in a
/// schema's [node specs](#model.NodeSpec.parseDOM), reordered by
/// [priority](#model.ParseRule.priority).
static fromSchema(schema: Schema) {
return schema.cached.domParser as DOMParser ||
(schema.cached.domParser = new DOMParser(schema, DOMParser.schemaRules(schema)))
}
}
const blockTags: {[tagName: string]: boolean} = {
address: true, article: true, aside: true, blockquote: true, canvas: true,
dd: true, div: true, dl: true, fieldset: true, figcaption: true, figure: true,
footer: true, form: true, h1: true, h2: true, h3: true, h4: true, h5: true,
h6: true, header: true, hgroup: true, hr: true, li: true, noscript: true, ol: true,
output: true, p: true, pre: true, section: true, table: true, tfoot: true, ul: true
}
const ignoreTags: {[tagName: string]: boolean} = {
head: true, noscript: true, object: true, script: true, style: true, title: true
}
const listTags: {[tagName: string]: boolean} = {ol: true, ul: true}
// Using a bitfield for node context options
const OPT_PRESERVE_WS = 1, OPT_PRESERVE_WS_FULL = 2, OPT_OPEN_LEFT = 4
function wsOptionsFor(type: NodeType | null, preserveWhitespace: boolean | "full" | undefined, base: number) {
if (preserveWhitespace != null) return (preserveWhitespace ? OPT_PRESERVE_WS : 0) |
(preserveWhitespace === "full" ? OPT_PRESERVE_WS_FULL : 0)
return type && type.whitespace == "pre" ? OPT_PRESERVE_WS | OPT_PRESERVE_WS_FULL : base & ~OPT_OPEN_LEFT
}
class NodeContext {
match: ContentMatch | null
content: Node[] = []
// Marks applied to the node's children
activeMarks: readonly Mark[] = Mark.none
// Nested Marks with same type
stashMarks: Mark[] = []
constructor(
readonly type: NodeType | null,
readonly attrs: Attrs | null,
// Marks applied to this node itself
readonly marks: readonly Mark[],
// Marks that can't apply here, but will be used in children if possible
public pendingMarks: readonly Mark[],
readonly solid: boolean,
match: ContentMatch | null,
readonly options: number
) {
this.match = match || (options & OPT_OPEN_LEFT ? null : type!.contentMatch)
}
findWrapping(node: Node) {
if (!this.match) {
if (!this.type) return []
let fill = this.type.contentMatch.fillBefore(Fragment.from(node))
if (fill) {
this.match = this.type.contentMatch.matchFragment(fill)!
} else {
let start = this.type.contentMatch, wrap
if (wrap = start.findWrapping(node.type)) {
this.match = start
return wrap
} else {
return null
}
}
}
return this.match.findWrapping(node.type)
}
finish(openEnd?: boolean): Node | Fragment {
if (!(this.options & OPT_PRESERVE_WS)) { // Strip trailing whitespace
let last = this.content[this.content.length - 1], m
if (last && last.isText && (m = /[ \t\r\n\u000c]+$/.exec(last.text!))) {
let text = last as TextNode
if (last.text!.length == m[0].length) this.content.pop()
else this.content[this.content.length - 1] = text.withText(text.text.slice(0, text.text.length - m[0].length))
}
}
let content = Fragment.from(this.content)
if (!openEnd && this.match)
content = content.append(this.match.fillBefore(Fragment.empty, true)!)
return this.type ? this.type.create(this.attrs, content, this.marks) : content
}
popFromStashMark(mark: Mark) {
for (let i = this.stashMarks.length - 1; i >= 0; i--)
if (mark.eq(this.stashMarks[i])) return this.stashMarks.splice(i, 1)[0]
}
applyPending(nextType: NodeType) {
for (let i = 0, pending = this.pendingMarks; i < pending.length; i++) {
let mark = pending[i]
if ((this.type ? this.type.allowsMarkType(mark.type) : markMayApply(mark.type, nextType)) &&
!mark.isInSet(this.activeMarks)) {
this.activeMarks = mark.addToSet(this.activeMarks)
this.pendingMarks = mark.removeFromSet(this.pendingMarks)
}
}
}
inlineContext(node: DOMNode) {
if (this.type) return this.type.inlineContent
if (this.content.length) return this.content[0].isInline
return node.parentNode && !blockTags.hasOwnProperty(node.parentNode.nodeName.toLowerCase())
}
}
class ParseContext {
open: number = 0
find: {node: DOMNode, offset: number, pos?: number}[] | undefined
needsBlock: boolean
nodes: NodeContext[]
constructor(
// The parser we are using.
readonly parser: DOMParser,
// The options passed to this parse.
readonly options: ParseOptions,
readonly isOpen: boolean
) {
let topNode = options.topNode, topContext: NodeContext
let topOptions = wsOptionsFor(null, options.preserveWhitespace, 0) | (isOpen ? OPT_OPEN_LEFT : 0)
if (topNode)
topContext = new NodeContext(topNode.type, topNode.attrs, Mark.none, Mark.none, true,
options.topMatch || topNode.type.contentMatch, topOptions)
else if (isOpen)
topContext = new NodeContext(null, null, Mark.none, Mark.none, true, null, topOptions)
else
topContext = new NodeContext(parser.schema.topNodeType, null, Mark.none, Mark.none, true, null, topOptions)
this.nodes = [topContext]
this.find = options.findPositions
this.needsBlock = false
}
get top() {
return this.nodes[this.open]
}
// Add a DOM node to the content. Text is inserted as text node,
// otherwise, the node is passed to `addElement` or, if it has a
// `style` attribute, `addElementWithStyles`.
addDOM(dom: DOMNode) {
if (dom.nodeType == 3) this.addTextNode(dom as Text)
else if (dom.nodeType == 1) this.addElement(dom as HTMLElement)
}
withStyleRules(dom: HTMLElement, f: () => void) {
let style = dom.getAttribute("style")
if (!style) return f()
let marks = this.readStyles(parseStyles(style))
if (!marks) return // A style with ignore: true
let [addMarks, removeMarks] = marks, top = this.top
for (let i = 0; i < removeMarks.length; i++) this.removePendingMark(removeMarks[i], top)
for (let i = 0; i < addMarks.length; i++) this.addPendingMark(addMarks[i])
f()
for (let i = 0; i < addMarks.length; i++) this.removePendingMark(addMarks[i], top)
for (let i = 0; i < removeMarks.length; i++) this.addPendingMark(removeMarks[i])
}
addTextNode(dom: Text) {
let value = dom.nodeValue!
let top = this.top
if (top.options & OPT_PRESERVE_WS_FULL ||
top.inlineContext(dom) ||
/[^ \t\r\n\u000c]/.test(value)) {
if (!(top.options & OPT_PRESERVE_WS)) {
value = value.replace(/[ \t\r\n\u000c]+/g, " ")
// If this starts with whitespace, and there is no node before it, or
// a hard break, or a text node that ends with whitespace, strip the
// leading space.
if (/^[ \t\r\n\u000c]/.test(value) && this.open == this.nodes.length - 1) {
let nodeBefore = top.content[top.content.length - 1]
let domNodeBefore = dom.previousSibling
if (!nodeBefore ||
(domNodeBefore && domNodeBefore.nodeName == 'BR') ||
(nodeBefore.isText && /[ \t\r\n\u000c]$/.test(nodeBefore.text!)))
value = value.slice(1)
}
} else if (!(top.options & OPT_PRESERVE_WS_FULL)) {
value = value.replace(/\r?\n|\r/g, " ")
} else {
value = value.replace(/\r\n?/g, "\n")
}
if (value) this.insertNode(this.parser.schema.text(value))
this.findInText(dom)
} else {
this.findInside(dom)
}
}
// Try to find a handler for the given tag and use that to parse. If
// none is found, the element's content nodes are added directly.
addElement(dom: HTMLElement, matchAfter?: TagParseRule) {
let name = dom.nodeName.toLowerCase(), ruleID: TagParseRule | undefined
if (listTags.hasOwnProperty(name) && this.parser.normalizeLists) normalizeList(dom)
let rule = (this.options.ruleFromNode && this.options.ruleFromNode(dom)) ||
(ruleID = this.parser.matchTag(dom, this, matchAfter))
if (rule ? rule.ignore : ignoreTags.hasOwnProperty(name)) {
this.findInside(dom)
this.ignoreFallback(dom)
} else if (!rule || rule.skip || rule.closeParent) {
if (rule && rule.closeParent) this.open = Math.max(0, this.open - 1)
else if (rule && (rule.skip as any).nodeType) dom = rule.skip as any as HTMLElement
let sync, top = this.top, oldNeedsBlock = this.needsBlock
if (blockTags.hasOwnProperty(name)) {
if (top.content.length && top.content[0].isInline && this.open) {
this.open--
top = this.top
}
sync = true
if (!top.type) this.needsBlock = true
} else if (!dom.firstChild) {
this.leafFallback(dom)
return
}
if (rule && rule.skip) this.addAll(dom)
else this.withStyleRules(dom, () => this.addAll(dom))
if (sync) this.sync(top)
this.needsBlock = oldNeedsBlock
} else {
this.withStyleRules(dom, () => {
this.addElementByRule(dom, rule as TagParseRule, rule!.consuming === false ? ruleID : undefined)
})
}
}
// Called for leaf DOM nodes that would otherwise be ignored
leafFallback(dom: DOMNode) {
if (dom.nodeName == "BR" && this.top.type && this.top.type.inlineContent)
this.addTextNode(dom.ownerDocument!.createTextNode("\n"))
}
// Called for ignored nodes
ignoreFallback(dom: DOMNode) {
// Ignored BR nodes should at least create an inline context
if (dom.nodeName == "BR" && (!this.top.type || !this.top.type.inlineContent))
this.findPlace(this.parser.schema.text("-"))
}
// Run any style parser associated with the node's styles. Either
// return an array of marks, or null to indicate some of the styles
// had a rule with `ignore` set.
readStyles(styles: readonly string[]) {
let add = Mark.none, remove = Mark.none
for (let i = 0; i < styles.length; i += 2) {
for (let after = undefined;;) {
let rule = this.parser.matchStyle(styles[i], styles[i + 1], this, after)
if (!rule) break
if (rule.ignore) return null
if (rule.clearMark) {
this.top.pendingMarks.concat(this.top.activeMarks).forEach(m => {
if (rule!.clearMark!(m)) remove = m.addToSet(remove)
})
} else {
add = this.parser.schema.marks[rule.mark!].create(rule.attrs).addToSet(add)
}
if (rule.consuming === false) after = rule
else break
}
}
return [add, remove]
}
// Look up a handler for the given node. If none are found, return
// false. Otherwise, apply it, use its return value to drive the way
// the node's content is wrapped, and return true.
addElementByRule(dom: HTMLElement, rule: TagParseRule, continueAfter?: TagParseRule) {
let sync, nodeType, mark
if (rule.node) {
nodeType = this.parser.schema.nodes[rule.node]
if (!nodeType.isLeaf) {
sync = this.enter(nodeType, rule.attrs || null, rule.preserveWhitespace)
} else if (!this.insertNode(nodeType.create(rule.attrs))) {
this.leafFallback(dom)
}
} else {
let markType = this.parser.schema.marks[rule.mark!]
mark = markType.create(rule.attrs)
this.addPendingMark(mark)
}
let startIn = this.top
if (nodeType && nodeType.isLeaf) {
this.findInside(dom)
} else if (continueAfter) {
this.addElement(dom, continueAfter)
} else if (rule.getContent) {
this.findInside(dom)
rule.getContent(dom, this.parser.schema).forEach(node => this.insertNode(node))
} else {
let contentDOM = dom
if (typeof rule.contentElement == "string") contentDOM = dom.querySelector(rule.contentElement)!
else if (typeof rule.contentElement == "function") contentDOM = rule.contentElement(dom)
else if (rule.contentElement) contentDOM = rule.contentElement
this.findAround(dom, contentDOM, true)
this.addAll(contentDOM)
}
if (sync && this.sync(startIn)) this.open--
if (mark) this.removePendingMark(mark, startIn)
}
// Add all child nodes between `startIndex` and `endIndex` (or the
// whole node, if not given). If `sync` is passed, use it to
// synchronize after every block element.
addAll(parent: DOMNode, startIndex?: number, endIndex?: number) {
let index = startIndex || 0
for (let dom = startIndex ? parent.childNodes[startIndex] : parent.firstChild,
end = endIndex == null ? null : parent.childNodes[endIndex];
dom != end; dom = dom!.nextSibling, ++index) {
this.findAtPoint(parent, index)
this.addDOM(dom!)
}
this.findAtPoint(parent, index)
}
// Try to find a way to fit the given node type into the current
// context. May add intermediate wrappers and/or leave non-solid
// nodes that we're in.
findPlace(node: Node) {
let route, sync: NodeContext | undefined
for (let depth = this.open; depth >= 0; depth--) {
let cx = this.nodes[depth]
let found = cx.findWrapping(node)
if (found && (!route || route.length > found.length)) {
route = found
sync = cx
if (!found.length) break
}
if (cx.solid) break
}
if (!route) return false
this.sync(sync!)
for (let i = 0; i < route.length; i++)
this.enterInner(route[i], null, false)
return true
}
// Try to insert the given node, adjusting the context when needed.
insertNode(node: Node) {
if (node.isInline && this.needsBlock && !this.top.type) {
let block = this.textblockFromContext()
if (block) this.enterInner(block)
}
if (this.findPlace(node)) {
this.closeExtra()
let top = this.top
top.applyPending(node.type)
if (top.match) top.match = top.match.matchType(node.type)
let marks = top.activeMarks
for (let i = 0; i < node.marks.length; i++)
if (!top.type || top.type.allowsMarkType(node.marks[i].type))
marks = node.marks[i].addToSet(marks)
top.content.push(node.mark(marks))
return true
}
return false
}
// Try to start a node of the given type, adjusting the context when
// necessary.
enter(type: NodeType, attrs: Attrs | null, preserveWS?: boolean | "full") {
let ok = this.findPlace(type.create(attrs))
if (ok) this.enterInner(type, attrs, true, preserveWS)
return ok
}
// Open a node of the given type
enterInner(type: NodeType, attrs: Attrs | null = null, solid: boolean = false, preserveWS?: boolean | "full") {
this.closeExtra()
let top = this.top
top.applyPending(type)
top.match = top.match && top.match.matchType(type)
let options = wsOptionsFor(type, preserveWS, top.options)
if ((top.options & OPT_OPEN_LEFT) && top.content.length == 0) options |= OPT_OPEN_LEFT
this.nodes.push(new NodeContext(type, attrs, top.activeMarks, top.pendingMarks, solid, null, options))
this.open++
}
// Make sure all nodes above this.open are finished and added to
// their parents
closeExtra(openEnd = false) {
let i = this.nodes.length - 1
if (i > this.open) {
for (; i > this.open; i--) this.nodes[i - 1].content.push(this.nodes[i].finish(openEnd) as Node)
this.nodes.length = this.open + 1
}
}
finish() {
this.open = 0
this.closeExtra(this.isOpen)
return this.nodes[0].finish(this.isOpen || this.options.topOpen)
}
sync(to: NodeContext) {
for (let i = this.open; i >= 0; i--) if (this.nodes[i] == to) {
this.open = i
return true
}
return false
}
get currentPos() {
this.closeExtra()
let pos = 0
for (let i = this.open; i >= 0; i--) {
let content = this.nodes[i].content
for (let j = content.length - 1; j >= 0; j--)
pos += content[j].nodeSize
if (i) pos++
}
return pos
}
findAtPoint(parent: DOMNode, offset: number) {
if (this.find) for (let i = 0; i < this.find.length; i++) {
if (this.find[i].node == parent && this.find[i].offset == offset)
this.find[i].pos = this.currentPos
}
}
findInside(parent: DOMNode) {
if (this.find) for (let i = 0; i < this.find.length; i++) {
if (this.find[i].pos == null && parent.nodeType == 1 && parent.contains(this.find[i].node))
this.find[i].pos = this.currentPos
}
}
findAround(parent: DOMNode, content: DOMNode, before: boolean) {
if (parent != content && this.find) for (let i = 0; i < this.find.length; i++) {
if (this.find[i].pos == null && parent.nodeType == 1 && parent.contains(this.find[i].node)) {
let pos = content.compareDocumentPosition(this.find[i].node)
if (pos & (before ? 2 : 4))
this.find[i].pos = this.currentPos
}
}
}
findInText(textNode: Text) {
if (this.find) for (let i = 0; i < this.find.length; i++) {
if (this.find[i].node == textNode)
this.find[i].pos = this.currentPos - (textNode.nodeValue!.length - this.find[i].offset)
}
}
// Determines whether the given context string matches this context.
matchesContext(context: string) {
if (context.indexOf("|") > -1)
return context.split(/\s*\|\s*/).some(this.matchesContext, this)
let parts = context.split("/")
let option = this.options.context
let useRoot = !this.isOpen && (!option || option.parent.type == this.nodes[0].type)
let minDepth = -(option ? option.depth + 1 : 0) + (useRoot ? 0 : 1)
let match = (i: number, depth: number) => {
for (; i >= 0; i--) {
let part = parts[i]
if (part == "") {
if (i == parts.length - 1 || i == 0) continue
for (; depth >= minDepth; depth--)
if (match(i - 1, depth)) return true
return false
} else {
let next = depth > 0 || (depth == 0 && useRoot) ? this.nodes[depth].type
: option && depth >= minDepth ? option.node(depth - minDepth).type
: null
if (!next || (next.name != part && next.groups.indexOf(part) == -1))
return false
depth--
}
}
return true
}
return match(parts.length - 1, this.open)
}
textblockFromContext() {
let $context = this.options.context
if ($context) for (let d = $context.depth; d >= 0; d--) {
let deflt = $context.node(d).contentMatchAt($context.indexAfter(d)).defaultType
if (deflt && deflt.isTextblock && deflt.defaultAttrs) return deflt
}
for (let name in this.parser.schema.nodes) {
let type = this.parser.schema.nodes[name]
if (type.isTextblock && type.defaultAttrs) return type
}
}
addPendingMark(mark: Mark) {
let found = findSameMarkInSet(mark, this.top.pendingMarks)
if (found) this.top.stashMarks.push(found)
this.top.pendingMarks = mark.addToSet(this.top.pendingMarks)
}
removePendingMark(mark: Mark, upto: NodeContext) {
for (let depth = this.open; depth >= 0; depth--) {
let level = this.nodes[depth]
let found = level.pendingMarks.lastIndexOf(mark)
if (found > -1) {
level.pendingMarks = mark.removeFromSet(level.pendingMarks)
} else {
level.activeMarks = mark.removeFromSet(level.activeMarks)
let stashMark = level.popFromStashMark(mark)
if (stashMark && level.type && level.type.allowsMarkType(stashMark.type))
level.activeMarks = stashMark.addToSet(level.activeMarks)
}
if (level == upto) break
}
}
}
// Kludge to work around directly nested list nodes produced by some
// tools and allowed by browsers to mean that the nested list is
// actually part of the list item above it.
function normalizeList(dom: DOMNode) {
for (let child = dom.firstChild, prevItem = null; child; child = child.nextSibling) {
let name = child.nodeType == 1 ? child.nodeName.toLowerCase() : null
if (name && listTags.hasOwnProperty(name) && prevItem) {
prevItem.appendChild(child)
child = prevItem
} else if (name == "li") {
prevItem = child
} else if (name) {
prevItem = null
}
}
}
// Apply a CSS selector.
function matches(dom: any, selector: string): boolean {
return (dom.matches || dom.msMatchesSelector || dom.webkitMatchesSelector || dom.mozMatchesSelector).call(dom, selector)
}
// Tokenize a style attribute into property/value pairs.
function parseStyles(style: string): string[] {
let re = /\s*([\w-]+)\s*:\s*([^;]+)/g, m, result = []
while (m = re.exec(style)) result.push(m[1], m[2].trim())
return result
}
function copy(obj: {[prop: string]: any}) {
let copy: {[prop: string]: any} = {}
for (let prop in obj) copy[prop] = obj[prop]
return copy
}
// Used when finding a mark at the top level of a fragment parse.
// Checks whether it would be reasonable to apply a given mark type to
// a given node, by looking at the way the mark occurs in the schema.
function markMayApply(markType: MarkType, nodeType: NodeType) {
let nodes = nodeType.schema.nodes
for (let name in nodes) {
let parent = nodes[name]
if (!parent.allowsMarkType(markType)) continue
let seen: ContentMatch[] = [], scan = (match: ContentMatch) => {
seen.push(match)
for (let i = 0; i < match.edgeCount; i++) {
let {type, next} = match.edge(i)
if (type == nodeType) return true
if (seen.indexOf(next) < 0 && scan(next)) return true
}
}
if (scan(parent.contentMatch)) return true
}
}
function findSameMarkInSet(mark: Mark, set: readonly Mark[]) {
for (let i = 0; i < set.length; i++) {
if (mark.eq(set[i])) return set[i]
}
}

View File

@@ -0,0 +1,11 @@
export {Node} from "./node"
export {ResolvedPos, NodeRange} from "./resolvedpos"
export {Fragment} from "./fragment"
export {Slice, ReplaceError} from "./replace"
export {Mark} from "./mark"
export {Schema, NodeType, Attrs, MarkType, NodeSpec, MarkSpec, AttributeSpec, SchemaSpec} from "./schema"
export {ContentMatch} from "./content"
export {DOMParser, GenericParseRule, TagParseRule, StyleParseRule, ParseRule, ParseOptions} from "./from_dom"
export {DOMSerializer, DOMOutputSpec} from "./to_dom"

View File

@@ -0,0 +1,109 @@
import {compareDeep} from "./comparedeep"
import {Attrs, MarkType, Schema} from "./schema"
/// A mark is a piece of information that can be attached to a node,
/// such as it being emphasized, in code font, or a link. It has a
/// type and optionally a set of attributes that provide further
/// information (such as the target of the link). Marks are created
/// through a `Schema`, which controls which types exist and which
/// attributes they have.
export class Mark {
/// @internal
constructor(
/// The type of this mark.
readonly type: MarkType,
/// The attributes associated with this mark.
readonly attrs: Attrs
) {}
/// Given a set of marks, create a new set which contains this one as
/// well, in the right position. If this mark is already in the set,
/// the set itself is returned. If any marks that are set to be
/// [exclusive](#model.MarkSpec.excludes) with this mark are present,
/// those are replaced by this one.
addToSet(set: readonly Mark[]): readonly Mark[] {
let copy, placed = false
for (let i = 0; i < set.length; i++) {
let other = set[i]
if (this.eq(other)) return set
if (this.type.excludes(other.type)) {
if (!copy) copy = set.slice(0, i)
} else if (other.type.excludes(this.type)) {
return set
} else {
if (!placed && other.type.rank > this.type.rank) {
if (!copy) copy = set.slice(0, i)
copy.push(this)
placed = true
}
if (copy) copy.push(other)
}
}
if (!copy) copy = set.slice()
if (!placed) copy.push(this)
return copy
}
/// Remove this mark from the given set, returning a new set. If this
/// mark is not in the set, the set itself is returned.
removeFromSet(set: readonly Mark[]): readonly Mark[] {
for (let i = 0; i < set.length; i++)
if (this.eq(set[i]))
return set.slice(0, i).concat(set.slice(i + 1))
return set
}
/// Test whether this mark is in the given set of marks.
isInSet(set: readonly Mark[]) {
for (let i = 0; i < set.length; i++)
if (this.eq(set[i])) return true
return false
}
/// Test whether this mark has the same type and attributes as
/// another mark.
eq(other: Mark) {
return this == other ||
(this.type == other.type && compareDeep(this.attrs, other.attrs))
}
/// Convert this mark to a JSON-serializeable representation.
toJSON(): any {
let obj: any = {type: this.type.name}
for (let _ in this.attrs) {
obj.attrs = this.attrs
break
}
return obj
}
/// Deserialize a mark from JSON.
static fromJSON(schema: Schema, json: any) {
if (!json) throw new RangeError("Invalid input for Mark.fromJSON")
let type = schema.marks[json.type]
if (!type) throw new RangeError(`There is no mark type ${json.type} in this schema`)
return type.create(json.attrs)
}
/// Test whether two sets of marks are identical.
static sameSet(a: readonly Mark[], b: readonly Mark[]) {
if (a == b) return true
if (a.length != b.length) return false
for (let i = 0; i < a.length; i++)
if (!a[i].eq(b[i])) return false
return true
}
/// Create a properly sorted mark set from null, a single mark, or an
/// unsorted array of marks.
static setFrom(marks?: Mark | readonly Mark[] | null): readonly Mark[] {
if (!marks || Array.isArray(marks) && marks.length == 0) return Mark.none
if (marks instanceof Mark) return [marks]
let copy = marks.slice()
copy.sort((a, b) => a.type.rank - b.type.rank)
return copy
}
/// The empty set of marks.
static none: readonly Mark[] = []
}

View File

@@ -0,0 +1,392 @@
import {Fragment} from "./fragment"
import {Mark} from "./mark"
import {Schema, NodeType, Attrs, MarkType} from "./schema"
import {Slice, replace} from "./replace"
import {ResolvedPos} from "./resolvedpos"
import {compareDeep} from "./comparedeep"
const emptyAttrs: Attrs = Object.create(null)
/// This class represents a node in the tree that makes up a
/// ProseMirror document. So a document is an instance of `Node`, with
/// children that are also instances of `Node`.
///
/// Nodes are persistent data structures. Instead of changing them, you
/// create new ones with the content you want. Old ones keep pointing
/// at the old document shape. This is made cheaper by sharing
/// structure between the old and new data as much as possible, which a
/// tree shape like this (without back pointers) makes easy.
///
/// **Do not** directly mutate the properties of a `Node` object. See
/// [the guide](/docs/guide/#doc) for more information.
export class Node {
/// @internal
constructor(
/// The type of node that this is.
readonly type: NodeType,
/// An object mapping attribute names to values. The kind of
/// attributes allowed and required are
/// [determined](#model.NodeSpec.attrs) by the node type.
readonly attrs: Attrs,
// A fragment holding the node's children.
content?: Fragment | null,
/// The marks (things like whether it is emphasized or part of a
/// link) applied to this node.
readonly marks = Mark.none
) {
this.content = content || Fragment.empty
}
/// A container holding the node's children.
readonly content: Fragment
/// For text nodes, this contains the node's text content.
readonly text: string | undefined
/// The size of this node, as defined by the integer-based [indexing
/// scheme](/docs/guide/#doc.indexing). For text nodes, this is the
/// amount of characters. For other leaf nodes, it is one. For
/// non-leaf nodes, it is the size of the content plus two (the
/// start and end token).
get nodeSize(): number { return this.isLeaf ? 1 : 2 + this.content.size }
/// The number of children that the node has.
get childCount() { return this.content.childCount }
/// Get the child node at the given index. Raises an error when the
/// index is out of range.
child(index: number) { return this.content.child(index) }
/// Get the child node at the given index, if it exists.
maybeChild(index: number) { return this.content.maybeChild(index) }
/// Call `f` for every child node, passing the node, its offset
/// into this parent node, and its index.
forEach(f: (node: Node, offset: number, index: number) => void) { this.content.forEach(f) }
/// Invoke a callback for all descendant nodes recursively between
/// the given two positions that are relative to start of this
/// node's content. The callback is invoked with the node, its
/// position relative to the original node (method receiver),
/// its parent node, and its child index. When the callback returns
/// false for a given node, that node's children will not be
/// recursed over. The last parameter can be used to specify a
/// starting position to count from.
nodesBetween(from: number, to: number,
f: (node: Node, pos: number, parent: Node | null, index: number) => void | boolean,
startPos = 0) {
this.content.nodesBetween(from, to, f, startPos, this)
}
/// Call the given callback for every descendant node. Doesn't
/// descend into a node when the callback returns `false`.
descendants(f: (node: Node, pos: number, parent: Node | null, index: number) => void | boolean) {
this.nodesBetween(0, this.content.size, f)
}
/// Concatenates all the text nodes found in this fragment and its
/// children.
get textContent() {
return (this.isLeaf && this.type.spec.leafText)
? this.type.spec.leafText(this)
: this.textBetween(0, this.content.size, "")
}
/// Get all text between positions `from` and `to`. When
/// `blockSeparator` is given, it will be inserted to separate text
/// from different block nodes. If `leafText` is given, it'll be
/// inserted for every non-text leaf node encountered, otherwise
/// [`leafText`](#model.NodeSpec^leafText) will be used.
textBetween(from: number, to: number, blockSeparator?: string | null,
leafText?: null | string | ((leafNode: Node) => string)) {
return this.content.textBetween(from, to, blockSeparator, leafText)
}
/// Returns this node's first child, or `null` if there are no
/// children.
get firstChild(): Node | null { return this.content.firstChild }
/// Returns this node's last child, or `null` if there are no
/// children.
get lastChild(): Node | null { return this.content.lastChild }
/// Test whether two nodes represent the same piece of document.
eq(other: Node) {
return this == other || (this.sameMarkup(other) && this.content.eq(other.content))
}
/// Compare the markup (type, attributes, and marks) of this node to
/// those of another. Returns `true` if both have the same markup.
sameMarkup(other: Node) {
return this.hasMarkup(other.type, other.attrs, other.marks)
}
/// Check whether this node's markup correspond to the given type,
/// attributes, and marks.
hasMarkup(type: NodeType, attrs?: Attrs | null, marks?: readonly Mark[]): boolean {
return this.type == type &&
compareDeep(this.attrs, attrs || type.defaultAttrs || emptyAttrs) &&
Mark.sameSet(this.marks, marks || Mark.none)
}
/// Create a new node with the same markup as this node, containing
/// the given content (or empty, if no content is given).
copy(content: Fragment | null = null): Node {
if (content == this.content) return this
return new Node(this.type, this.attrs, content, this.marks)
}
/// Create a copy of this node, with the given set of marks instead
/// of the node's own marks.
mark(marks: readonly Mark[]): Node {
return marks == this.marks ? this : new Node(this.type, this.attrs, this.content, marks)
}
/// Create a copy of this node with only the content between the
/// given positions. If `to` is not given, it defaults to the end of
/// the node.
cut(from: number, to: number = this.content.size): Node {
if (from == 0 && to == this.content.size) return this
return this.copy(this.content.cut(from, to))
}
/// Cut out the part of the document between the given positions, and
/// return it as a `Slice` object.
slice(from: number, to: number = this.content.size, includeParents = false) {
if (from == to) return Slice.empty
let $from = this.resolve(from), $to = this.resolve(to)
let depth = includeParents ? 0 : $from.sharedDepth(to)
let start = $from.start(depth), node = $from.node(depth)
let content = node.content.cut($from.pos - start, $to.pos - start)
return new Slice(content, $from.depth - depth, $to.depth - depth)
}
/// Replace the part of the document between the given positions with
/// the given slice. The slice must 'fit', meaning its open sides
/// must be able to connect to the surrounding content, and its
/// content nodes must be valid children for the node they are placed
/// into. If any of this is violated, an error of type
/// [`ReplaceError`](#model.ReplaceError) is thrown.
replace(from: number, to: number, slice: Slice) {
return replace(this.resolve(from), this.resolve(to), slice)
}
/// Find the node directly after the given position.
nodeAt(pos: number): Node | null {
for (let node: Node | null = this;;) {
let {index, offset} = node.content.findIndex(pos)
node = node.maybeChild(index)
if (!node) return null
if (offset == pos || node.isText) return node
pos -= offset + 1
}
}
/// Find the (direct) child node after the given offset, if any,
/// and return it along with its index and offset relative to this
/// node.
childAfter(pos: number): {node: Node | null, index: number, offset: number} {
let {index, offset} = this.content.findIndex(pos)
return {node: this.content.maybeChild(index), index, offset}
}
/// Find the (direct) child node before the given offset, if any,
/// and return it along with its index and offset relative to this
/// node.
childBefore(pos: number): {node: Node | null, index: number, offset: number} {
if (pos == 0) return {node: null, index: 0, offset: 0}
let {index, offset} = this.content.findIndex(pos)
if (offset < pos) return {node: this.content.child(index), index, offset}
let node = this.content.child(index - 1)
return {node, index: index - 1, offset: offset - node.nodeSize}
}
/// Resolve the given position in the document, returning an
/// [object](#model.ResolvedPos) with information about its context.
resolve(pos: number) { return ResolvedPos.resolveCached(this, pos) }
/// @internal
resolveNoCache(pos: number) { return ResolvedPos.resolve(this, pos) }
/// Test whether a given mark or mark type occurs in this document
/// between the two given positions.
rangeHasMark(from: number, to: number, type: Mark | MarkType): boolean {
let found = false
if (to > from) this.nodesBetween(from, to, node => {
if (type.isInSet(node.marks)) found = true
return !found
})
return found
}
/// True when this is a block (non-inline node)
get isBlock() { return this.type.isBlock }
/// True when this is a textblock node, a block node with inline
/// content.
get isTextblock() { return this.type.isTextblock }
/// True when this node allows inline content.
get inlineContent() { return this.type.inlineContent }
/// True when this is an inline node (a text node or a node that can
/// appear among text).
get isInline() { return this.type.isInline }
/// True when this is a text node.
get isText() { return this.type.isText }
/// True when this is a leaf node.
get isLeaf() { return this.type.isLeaf }
/// True when this is an atom, i.e. when it does not have directly
/// editable content. This is usually the same as `isLeaf`, but can
/// be configured with the [`atom` property](#model.NodeSpec.atom)
/// on a node's spec (typically used when the node is displayed as
/// an uneditable [node view](#view.NodeView)).
get isAtom() { return this.type.isAtom }
/// Return a string representation of this node for debugging
/// purposes.
toString(): string {
if (this.type.spec.toDebugString) return this.type.spec.toDebugString(this)
let name = this.type.name
if (this.content.size)
name += "(" + this.content.toStringInner() + ")"
return wrapMarks(this.marks, name)
}
/// Get the content match in this node at the given index.
contentMatchAt(index: number) {
let match = this.type.contentMatch.matchFragment(this.content, 0, index)
if (!match) throw new Error("Called contentMatchAt on a node with invalid content")
return match
}
/// Test whether replacing the range between `from` and `to` (by
/// child index) with the given replacement fragment (which defaults
/// to the empty fragment) would leave the node's content valid. You
/// can optionally pass `start` and `end` indices into the
/// replacement fragment.
canReplace(from: number, to: number, replacement = Fragment.empty, start = 0, end = replacement.childCount) {
let one = this.contentMatchAt(from).matchFragment(replacement, start, end)
let two = one && one.matchFragment(this.content, to)
if (!two || !two.validEnd) return false
for (let i = start; i < end; i++) if (!this.type.allowsMarks(replacement.child(i).marks)) return false
return true
}
/// Test whether replacing the range `from` to `to` (by index) with
/// a node of the given type would leave the node's content valid.
canReplaceWith(from: number, to: number, type: NodeType, marks?: readonly Mark[]) {
if (marks && !this.type.allowsMarks(marks)) return false
let start = this.contentMatchAt(from).matchType(type)
let end = start && start.matchFragment(this.content, to)
return end ? end.validEnd : false
}
/// Test whether the given node's content could be appended to this
/// node. If that node is empty, this will only return true if there
/// is at least one node type that can appear in both nodes (to avoid
/// merging completely incompatible nodes).
canAppend(other: Node) {
if (other.content.size) return this.canReplace(this.childCount, this.childCount, other.content)
else return this.type.compatibleContent(other.type)
}
/// Check whether this node and its descendants conform to the
/// schema, and raise error when they do not.
check() {
this.type.checkContent(this.content)
let copy = Mark.none
for (let i = 0; i < this.marks.length; i++) copy = this.marks[i].addToSet(copy)
if (!Mark.sameSet(copy, this.marks))
throw new RangeError(`Invalid collection of marks for node ${this.type.name}: ${this.marks.map(m => m.type.name)}`)
this.content.forEach(node => node.check())
}
/// Return a JSON-serializeable representation of this node.
toJSON(): any {
let obj: any = {type: this.type.name}
for (let _ in this.attrs) {
obj.attrs = this.attrs
break
}
if (this.content.size)
obj.content = this.content.toJSON()
if (this.marks.length)
obj.marks = this.marks.map(n => n.toJSON())
return obj
}
/// Deserialize a node from its JSON representation.
static fromJSON(schema: Schema, json: any): Node {
if (!json) throw new RangeError("Invalid input for Node.fromJSON")
let marks = null
if (json.marks) {
if (!Array.isArray(json.marks)) throw new RangeError("Invalid mark data for Node.fromJSON")
marks = json.marks.map(schema.markFromJSON)
}
if (json.type == "text") {
if (typeof json.text != "string") throw new RangeError("Invalid text node in JSON")
return schema.text(json.text, marks)
}
let content = Fragment.fromJSON(schema, json.content)
return schema.nodeType(json.type).create(json.attrs, content, marks)
}
}
;(Node.prototype as any).text = undefined
export class TextNode extends Node {
readonly text: string
/// @internal
constructor(type: NodeType, attrs: Attrs, content: string, marks?: readonly Mark[]) {
super(type, attrs, null, marks)
if (!content) throw new RangeError("Empty text nodes are not allowed")
this.text = content
}
toString() {
if (this.type.spec.toDebugString) return this.type.spec.toDebugString(this)
return wrapMarks(this.marks, JSON.stringify(this.text))
}
get textContent() { return this.text }
textBetween(from: number, to: number) { return this.text.slice(from, to) }
get nodeSize() { return this.text.length }
mark(marks: readonly Mark[]) {
return marks == this.marks ? this : new TextNode(this.type, this.attrs, this.text, marks)
}
withText(text: string) {
if (text == this.text) return this
return new TextNode(this.type, this.attrs, text, this.marks)
}
cut(from = 0, to = this.text.length) {
if (from == 0 && to == this.text.length) return this
return this.withText(this.text.slice(from, to))
}
eq(other: Node) {
return this.sameMarkup(other) && this.text == other.text
}
toJSON() {
let base = super.toJSON()
base.text = this.text
return base
}
}
function wrapMarks(marks: readonly Mark[], str: string) {
for (let i = marks.length - 1; i >= 0; i--)
str = marks[i].type.name + "(" + str + ")"
return str
}

View File

@@ -0,0 +1,225 @@
import {Fragment} from "./fragment"
import {Schema} from "./schema"
import {Node, TextNode} from "./node"
import {ResolvedPos} from "./resolvedpos"
/// Error type raised by [`Node.replace`](#model.Node.replace) when
/// given an invalid replacement.
export class ReplaceError extends Error {}
/*
ReplaceError = function(this: any, message: string) {
let err = Error.call(this, message)
;(err as any).__proto__ = ReplaceError.prototype
return err
} as any
ReplaceError.prototype = Object.create(Error.prototype)
ReplaceError.prototype.constructor = ReplaceError
ReplaceError.prototype.name = "ReplaceError"
*/
/// A slice represents a piece cut out of a larger document. It
/// stores not only a fragment, but also the depth up to which nodes on
/// both side are open (cut through).
export class Slice {
/// Create a slice. When specifying a non-zero open depth, you must
/// make sure that there are nodes of at least that depth at the
/// appropriate side of the fragment—i.e. if the fragment is an
/// empty paragraph node, `openStart` and `openEnd` can't be greater
/// than 1.
///
/// It is not necessary for the content of open nodes to conform to
/// the schema's content constraints, though it should be a valid
/// start/end/middle for such a node, depending on which sides are
/// open.
constructor(
/// The slice's content.
readonly content: Fragment,
/// The open depth at the start of the fragment.
readonly openStart: number,
/// The open depth at the end.
readonly openEnd: number
) {}
/// The size this slice would add when inserted into a document.
get size(): number {
return this.content.size - this.openStart - this.openEnd
}
/// @internal
insertAt(pos: number, fragment: Fragment) {
let content = insertInto(this.content, pos + this.openStart, fragment)
return content && new Slice(content, this.openStart, this.openEnd)
}
/// @internal
removeBetween(from: number, to: number) {
return new Slice(removeRange(this.content, from + this.openStart, to + this.openStart), this.openStart, this.openEnd)
}
/// Tests whether this slice is equal to another slice.
eq(other: Slice): boolean {
return this.content.eq(other.content) && this.openStart == other.openStart && this.openEnd == other.openEnd
}
/// @internal
toString() {
return this.content + "(" + this.openStart + "," + this.openEnd + ")"
}
/// Convert a slice to a JSON-serializable representation.
toJSON(): any {
if (!this.content.size) return null
let json: any = {content: this.content.toJSON()}
if (this.openStart > 0) json.openStart = this.openStart
if (this.openEnd > 0) json.openEnd = this.openEnd
return json
}
/// Deserialize a slice from its JSON representation.
static fromJSON(schema: Schema, json: any): Slice {
if (!json) return Slice.empty
let openStart = json.openStart || 0, openEnd = json.openEnd || 0
if (typeof openStart != "number" || typeof openEnd != "number")
throw new RangeError("Invalid input for Slice.fromJSON")
return new Slice(Fragment.fromJSON(schema, json.content), openStart, openEnd)
}
/// Create a slice from a fragment by taking the maximum possible
/// open value on both side of the fragment.
static maxOpen(fragment: Fragment, openIsolating = true) {
let openStart = 0, openEnd = 0
for (let n = fragment.firstChild; n && !n.isLeaf && (openIsolating || !n.type.spec.isolating); n = n.firstChild) openStart++
for (let n = fragment.lastChild; n && !n.isLeaf && (openIsolating || !n.type.spec.isolating); n = n.lastChild) openEnd++
return new Slice(fragment, openStart, openEnd)
}
/// The empty slice.
static empty = new Slice(Fragment.empty, 0, 0)
}
function removeRange(content: Fragment, from: number, to: number): Fragment {
let {index, offset} = content.findIndex(from), child = content.maybeChild(index)
let {index: indexTo, offset: offsetTo} = content.findIndex(to)
if (offset == from || child!.isText) {
if (offsetTo != to && !content.child(indexTo).isText) throw new RangeError("Removing non-flat range")
return content.cut(0, from).append(content.cut(to))
}
if (index != indexTo) throw new RangeError("Removing non-flat range")
return content.replaceChild(index, child!.copy(removeRange(child!.content, from - offset - 1, to - offset - 1)))
}
function insertInto(content: Fragment, dist: number, insert: Fragment, parent?: Node): Fragment | null {
let {index, offset} = content.findIndex(dist), child = content.maybeChild(index)
if (offset == dist || child!.isText) {
if (parent && !parent.canReplace(index, index, insert)) return null
return content.cut(0, dist).append(insert).append(content.cut(dist))
}
let inner = insertInto(child!.content, dist - offset - 1, insert)
return inner && content.replaceChild(index, child!.copy(inner))
}
export function replace($from: ResolvedPos, $to: ResolvedPos, slice: Slice) {
if (slice.openStart > $from.depth)
throw new ReplaceError("Inserted content deeper than insertion position")
if ($from.depth - slice.openStart != $to.depth - slice.openEnd)
throw new ReplaceError("Inconsistent open depths")
return replaceOuter($from, $to, slice, 0)
}
function replaceOuter($from: ResolvedPos, $to: ResolvedPos, slice: Slice, depth: number): Node {
let index = $from.index(depth), node = $from.node(depth)
if (index == $to.index(depth) && depth < $from.depth - slice.openStart) {
let inner = replaceOuter($from, $to, slice, depth + 1)
return node.copy(node.content.replaceChild(index, inner))
} else if (!slice.content.size) {
return close(node, replaceTwoWay($from, $to, depth))
} else if (!slice.openStart && !slice.openEnd && $from.depth == depth && $to.depth == depth) { // Simple, flat case
let parent = $from.parent, content = parent.content
return close(parent, content.cut(0, $from.parentOffset).append(slice.content).append(content.cut($to.parentOffset)))
} else {
let {start, end} = prepareSliceForReplace(slice, $from)
return close(node, replaceThreeWay($from, start, end, $to, depth))
}
}
function checkJoin(main: Node, sub: Node) {
if (!sub.type.compatibleContent(main.type))
throw new ReplaceError("Cannot join " + sub.type.name + " onto " + main.type.name)
}
function joinable($before: ResolvedPos, $after: ResolvedPos, depth: number) {
let node = $before.node(depth)
checkJoin(node, $after.node(depth))
return node
}
function addNode(child: Node, target: Node[]) {
let last = target.length - 1
if (last >= 0 && child.isText && child.sameMarkup(target[last]))
target[last] = (child as TextNode).withText(target[last].text! + child.text!)
else
target.push(child)
}
function addRange($start: ResolvedPos | null, $end: ResolvedPos | null, depth: number, target: Node[]) {
let node = ($end || $start)!.node(depth)
let startIndex = 0, endIndex = $end ? $end.index(depth) : node.childCount
if ($start) {
startIndex = $start.index(depth)
if ($start.depth > depth) {
startIndex++
} else if ($start.textOffset) {
addNode($start.nodeAfter!, target)
startIndex++
}
}
for (let i = startIndex; i < endIndex; i++) addNode(node.child(i), target)
if ($end && $end.depth == depth && $end.textOffset)
addNode($end.nodeBefore!, target)
}
function close(node: Node, content: Fragment) {
node.type.checkContent(content)
return node.copy(content)
}
function replaceThreeWay($from: ResolvedPos, $start: ResolvedPos, $end: ResolvedPos, $to: ResolvedPos, depth: number) {
let openStart = $from.depth > depth && joinable($from, $start, depth + 1)
let openEnd = $to.depth > depth && joinable($end, $to, depth + 1)
let content: Node[] = []
addRange(null, $from, depth, content)
if (openStart && openEnd && $start.index(depth) == $end.index(depth)) {
checkJoin(openStart, openEnd)
addNode(close(openStart, replaceThreeWay($from, $start, $end, $to, depth + 1)), content)
} else {
if (openStart)
addNode(close(openStart, replaceTwoWay($from, $start, depth + 1)), content)
addRange($start, $end, depth, content)
if (openEnd)
addNode(close(openEnd, replaceTwoWay($end, $to, depth + 1)), content)
}
addRange($to, null, depth, content)
return new Fragment(content)
}
function replaceTwoWay($from: ResolvedPos, $to: ResolvedPos, depth: number) {
let content: Node[] = []
addRange(null, $from, depth, content)
if ($from.depth > depth) {
let type = joinable($from, $to, depth + 1)
addNode(close(type, replaceTwoWay($from, $to, depth + 1)), content)
}
addRange($to, null, depth, content)
return new Fragment(content)
}
function prepareSliceForReplace(slice: Slice, $along: ResolvedPos) {
let extra = $along.depth - slice.openStart, parent = $along.node(extra)
let node = parent.copy(slice.content)
for (let i = extra - 1; i >= 0; i--)
node = $along.node(i).copy(Fragment.from(node))
return {start: node.resolveNoCache(slice.openStart + extra),
end: node.resolveNoCache(node.content.size - slice.openEnd - extra)}
}

View File

@@ -0,0 +1,279 @@
import {Mark} from "./mark"
import {Node} from "./node"
/// You can [_resolve_](#model.Node.resolve) a position to get more
/// information about it. Objects of this class represent such a
/// resolved position, providing various pieces of context
/// information, and some helper methods.
///
/// Throughout this interface, methods that take an optional `depth`
/// parameter will interpret undefined as `this.depth` and negative
/// numbers as `this.depth + value`.
export class ResolvedPos {
/// The number of levels the parent node is from the root. If this
/// position points directly into the root node, it is 0. If it
/// points into a top-level paragraph, 1, and so on.
depth: number
/// @internal
constructor(
/// The position that was resolved.
readonly pos: number,
/// @internal
readonly path: any[],
/// The offset this position has into its parent node.
readonly parentOffset: number
) {
this.depth = path.length / 3 - 1
}
/// @internal
resolveDepth(val: number | undefined | null) {
if (val == null) return this.depth
if (val < 0) return this.depth + val
return val
}
/// The parent node that the position points into. Note that even if
/// a position points into a text node, that node is not considered
/// the parent—text nodes are flat in this model, and have no content.
get parent() { return this.node(this.depth) }
/// The root node in which the position was resolved.
get doc() { return this.node(0) }
/// The ancestor node at the given level. `p.node(p.depth)` is the
/// same as `p.parent`.
node(depth?: number | null): Node { return this.path[this.resolveDepth(depth) * 3] }
/// The index into the ancestor at the given level. If this points
/// at the 3rd node in the 2nd paragraph on the top level, for
/// example, `p.index(0)` is 1 and `p.index(1)` is 2.
index(depth?: number | null): number { return this.path[this.resolveDepth(depth) * 3 + 1] }
/// The index pointing after this position into the ancestor at the
/// given level.
indexAfter(depth?: number | null): number {
depth = this.resolveDepth(depth)
return this.index(depth) + (depth == this.depth && !this.textOffset ? 0 : 1)
}
/// The (absolute) position at the start of the node at the given
/// level.
start(depth?: number | null): number {
depth = this.resolveDepth(depth)
return depth == 0 ? 0 : this.path[depth * 3 - 1] + 1
}
/// The (absolute) position at the end of the node at the given
/// level.
end(depth?: number | null): number {
depth = this.resolveDepth(depth)
return this.start(depth) + this.node(depth).content.size
}
/// The (absolute) position directly before the wrapping node at the
/// given level, or, when `depth` is `this.depth + 1`, the original
/// position.
before(depth?: number | null): number {
depth = this.resolveDepth(depth)
if (!depth) throw new RangeError("There is no position before the top-level node")
return depth == this.depth + 1 ? this.pos : this.path[depth * 3 - 1]
}
/// The (absolute) position directly after the wrapping node at the
/// given level, or the original position when `depth` is `this.depth + 1`.
after(depth?: number | null): number {
depth = this.resolveDepth(depth)
if (!depth) throw new RangeError("There is no position after the top-level node")
return depth == this.depth + 1 ? this.pos : this.path[depth * 3 - 1] + this.path[depth * 3].nodeSize
}
/// When this position points into a text node, this returns the
/// distance between the position and the start of the text node.
/// Will be zero for positions that point between nodes.
get textOffset(): number { return this.pos - this.path[this.path.length - 1] }
/// Get the node directly after the position, if any. If the position
/// points into a text node, only the part of that node after the
/// position is returned.
get nodeAfter(): Node | null {
let parent = this.parent, index = this.index(this.depth)
if (index == parent.childCount) return null
let dOff = this.pos - this.path[this.path.length - 1], child = parent.child(index)
return dOff ? parent.child(index).cut(dOff) : child
}
/// Get the node directly before the position, if any. If the
/// position points into a text node, only the part of that node
/// before the position is returned.
get nodeBefore(): Node | null {
let index = this.index(this.depth)
let dOff = this.pos - this.path[this.path.length - 1]
if (dOff) return this.parent.child(index).cut(0, dOff)
return index == 0 ? null : this.parent.child(index - 1)
}
/// Get the position at the given index in the parent node at the
/// given depth (which defaults to `this.depth`).
posAtIndex(index: number, depth?: number | null): number {
depth = this.resolveDepth(depth)
let node = this.path[depth * 3], pos = depth == 0 ? 0 : this.path[depth * 3 - 1] + 1
for (let i = 0; i < index; i++) pos += node.child(i).nodeSize
return pos
}
/// Get the marks at this position, factoring in the surrounding
/// marks' [`inclusive`](#model.MarkSpec.inclusive) property. If the
/// position is at the start of a non-empty node, the marks of the
/// node after it (if any) are returned.
marks(): readonly Mark[] {
let parent = this.parent, index = this.index()
// In an empty parent, return the empty array
if (parent.content.size == 0) return Mark.none
// When inside a text node, just return the text node's marks
if (this.textOffset) return parent.child(index).marks
let main = parent.maybeChild(index - 1), other = parent.maybeChild(index)
// If the `after` flag is true of there is no node before, make
// the node after this position the main reference.
if (!main) { let tmp = main; main = other; other = tmp }
// Use all marks in the main node, except those that have
// `inclusive` set to false and are not present in the other node.
let marks = main!.marks
for (var i = 0; i < marks.length; i++)
if (marks[i].type.spec.inclusive === false && (!other || !marks[i].isInSet(other.marks)))
marks = marks[i--].removeFromSet(marks)
return marks
}
/// Get the marks after the current position, if any, except those
/// that are non-inclusive and not present at position `$end`. This
/// is mostly useful for getting the set of marks to preserve after a
/// deletion. Will return `null` if this position is at the end of
/// its parent node or its parent node isn't a textblock (in which
/// case no marks should be preserved).
marksAcross($end: ResolvedPos): readonly Mark[] | null {
let after = this.parent.maybeChild(this.index())
if (!after || !after.isInline) return null
let marks = after.marks, next = $end.parent.maybeChild($end.index())
for (var i = 0; i < marks.length; i++)
if (marks[i].type.spec.inclusive === false && (!next || !marks[i].isInSet(next.marks)))
marks = marks[i--].removeFromSet(marks)
return marks
}
/// The depth up to which this position and the given (non-resolved)
/// position share the same parent nodes.
sharedDepth(pos: number): number {
for (let depth = this.depth; depth > 0; depth--)
if (this.start(depth) <= pos && this.end(depth) >= pos) return depth
return 0
}
/// Returns a range based on the place where this position and the
/// given position diverge around block content. If both point into
/// the same textblock, for example, a range around that textblock
/// will be returned. If they point into different blocks, the range
/// around those blocks in their shared ancestor is returned. You can
/// pass in an optional predicate that will be called with a parent
/// node to see if a range into that parent is acceptable.
blockRange(other: ResolvedPos = this, pred?: (node: Node) => boolean): NodeRange | null {
if (other.pos < this.pos) return other.blockRange(this)
for (let d = this.depth - (this.parent.inlineContent || this.pos == other.pos ? 1 : 0); d >= 0; d--)
if (other.pos <= this.end(d) && (!pred || pred(this.node(d))))
return new NodeRange(this, other, d)
return null
}
/// Query whether the given position shares the same parent node.
sameParent(other: ResolvedPos): boolean {
return this.pos - this.parentOffset == other.pos - other.parentOffset
}
/// Return the greater of this and the given position.
max(other: ResolvedPos): ResolvedPos {
return other.pos > this.pos ? other : this
}
/// Return the smaller of this and the given position.
min(other: ResolvedPos): ResolvedPos {
return other.pos < this.pos ? other : this
}
/// @internal
toString() {
let str = ""
for (let i = 1; i <= this.depth; i++)
str += (str ? "/" : "") + this.node(i).type.name + "_" + this.index(i - 1)
return str + ":" + this.parentOffset
}
/// @internal
static resolve(doc: Node, pos: number): ResolvedPos {
if (!(pos >= 0 && pos <= doc.content.size)) throw new RangeError("Position " + pos + " out of range")
let path = []
let start = 0, parentOffset = pos
for (let node = doc;;) {
let {index, offset} = node.content.findIndex(parentOffset)
let rem = parentOffset - offset
path.push(node, index, start + offset)
if (!rem) break
node = node.child(index)
if (node.isText) break
parentOffset = rem - 1
start += offset + 1
}
return new ResolvedPos(pos, path, parentOffset)
}
/// @internal
static resolveCached(doc: Node, pos: number): ResolvedPos {
for (let i = 0; i < resolveCache.length; i++) {
let cached = resolveCache[i]
if (cached.pos == pos && cached.doc == doc) return cached
}
let result = resolveCache[resolveCachePos] = ResolvedPos.resolve(doc, pos)
resolveCachePos = (resolveCachePos + 1) % resolveCacheSize
return result
}
}
let resolveCache: ResolvedPos[] = [], resolveCachePos = 0, resolveCacheSize = 12
/// Represents a flat range of content, i.e. one that starts and
/// ends in the same node.
export class NodeRange {
/// Construct a node range. `$from` and `$to` should point into the
/// same node until at least the given `depth`, since a node range
/// denotes an adjacent set of nodes in a single parent node.
constructor(
/// A resolved position along the start of the content. May have a
/// `depth` greater than this object's `depth` property, since
/// these are the positions that were used to compute the range,
/// not re-resolved positions directly at its boundaries.
readonly $from: ResolvedPos,
/// A position along the end of the content. See
/// caveat for [`$from`](#model.NodeRange.$from).
readonly $to: ResolvedPos,
/// The depth of the node that this range points into.
readonly depth: number
) {}
/// The position at the start of the range.
get start() { return this.$from.before(this.depth + 1) }
/// The position at the end of the range.
get end() { return this.$to.after(this.depth + 1) }
/// The parent node that the range points into.
get parent() { return this.$from.node(this.depth) }
/// The start index of the range in the parent node.
get startIndex() { return this.$from.index(this.depth) }
/// The end index of the range in the parent node.
get endIndex() { return this.$to.indexAfter(this.depth) }
}

View File

@@ -0,0 +1,661 @@
import OrderedMap from "orderedmap"
import {Node, TextNode} from "./node"
import {Fragment} from "./fragment"
import {Mark} from "./mark"
import {ContentMatch} from "./content"
import {DOMOutputSpec} from "./to_dom"
import {ParseRule, TagParseRule} from "./from_dom"
/// An object holding the attributes of a node.
export type Attrs = {readonly [attr: string]: any}
// For node types where all attrs have a default value (or which don't
// have any attributes), build up a single reusable default attribute
// object, and use it for all nodes that don't specify specific
// attributes.
function defaultAttrs(attrs: Attrs) {
let defaults = Object.create(null)
for (let attrName in attrs) {
let attr = attrs[attrName]
if (!attr.hasDefault) return null
defaults[attrName] = attr.default
}
return defaults
}
function computeAttrs(attrs: Attrs, value: Attrs | null) {
let built = Object.create(null)
for (let name in attrs) {
let given = value && value[name]
if (given === undefined) {
let attr = attrs[name]
if (attr.hasDefault) given = attr.default
else throw new RangeError("No value supplied for attribute " + name)
}
built[name] = given
}
return built
}
function initAttrs(attrs?: {[name: string]: AttributeSpec}) {
let result: {[name: string]: Attribute} = Object.create(null)
if (attrs) for (let name in attrs) result[name] = new Attribute(attrs[name])
return result
}
/// Node types are objects allocated once per `Schema` and used to
/// [tag](#model.Node.type) `Node` instances. They contain information
/// about the node type, such as its name and what kind of node it
/// represents.
export class NodeType {
/// @internal
groups: readonly string[]
/// @internal
attrs: {[name: string]: Attribute}
/// @internal
defaultAttrs: Attrs
/// @internal
constructor(
/// The name the node type has in this schema.
readonly name: string,
/// A link back to the `Schema` the node type belongs to.
readonly schema: Schema,
/// The spec that this type is based on
readonly spec: NodeSpec
) {
this.groups = spec.group ? spec.group.split(" ") : []
this.attrs = initAttrs(spec.attrs)
this.defaultAttrs = defaultAttrs(this.attrs)
// Filled in later
;(this as any).contentMatch = null
;(this as any).inlineContent = null
this.isBlock = !(spec.inline || name == "text")
this.isText = name == "text"
}
/// True if this node type has inline content.
inlineContent!: boolean
/// True if this is a block type
isBlock: boolean
/// True if this is the text node type.
isText: boolean
/// True if this is an inline type.
get isInline() { return !this.isBlock }
/// True if this is a textblock type, a block that contains inline
/// content.
get isTextblock() { return this.isBlock && this.inlineContent }
/// True for node types that allow no content.
get isLeaf() { return this.contentMatch == ContentMatch.empty }
/// True when this node is an atom, i.e. when it does not have
/// directly editable content.
get isAtom() { return this.isLeaf || !!this.spec.atom }
/// The starting match of the node type's content expression.
contentMatch!: ContentMatch
/// The set of marks allowed in this node. `null` means all marks
/// are allowed.
markSet: readonly MarkType[] | null = null
/// The node type's [whitespace](#model.NodeSpec.whitespace) option.
get whitespace(): "pre" | "normal" {
return this.spec.whitespace || (this.spec.code ? "pre" : "normal")
}
/// Tells you whether this node type has any required attributes.
hasRequiredAttrs() {
for (let n in this.attrs) if (this.attrs[n].isRequired) return true
return false
}
/// Indicates whether this node allows some of the same content as
/// the given node type.
compatibleContent(other: NodeType) {
return this == other || this.contentMatch.compatible(other.contentMatch)
}
/// @internal
computeAttrs(attrs: Attrs | null): Attrs {
if (!attrs && this.defaultAttrs) return this.defaultAttrs
else return computeAttrs(this.attrs, attrs)
}
/// Create a `Node` of this type. The given attributes are
/// checked and defaulted (you can pass `null` to use the type's
/// defaults entirely, if no required attributes exist). `content`
/// may be a `Fragment`, a node, an array of nodes, or
/// `null`. Similarly `marks` may be `null` to default to the empty
/// set of marks.
create(attrs: Attrs | null = null, content?: Fragment | Node | readonly Node[] | null, marks?: readonly Mark[]) {
if (this.isText) throw new Error("NodeType.create can't construct text nodes")
return new Node(this, this.computeAttrs(attrs), Fragment.from(content), Mark.setFrom(marks))
}
/// Like [`create`](#model.NodeType.create), but check the given content
/// against the node type's content restrictions, and throw an error
/// if it doesn't match.
createChecked(attrs: Attrs | null = null, content?: Fragment | Node | readonly Node[] | null, marks?: readonly Mark[]) {
content = Fragment.from(content)
this.checkContent(content)
return new Node(this, this.computeAttrs(attrs), content, Mark.setFrom(marks))
}
/// Like [`create`](#model.NodeType.create), but see if it is
/// necessary to add nodes to the start or end of the given fragment
/// to make it fit the node. If no fitting wrapping can be found,
/// return null. Note that, due to the fact that required nodes can
/// always be created, this will always succeed if you pass null or
/// `Fragment.empty` as content.
createAndFill(attrs: Attrs | null = null, content?: Fragment | Node | readonly Node[] | null, marks?: readonly Mark[]) {
attrs = this.computeAttrs(attrs)
content = Fragment.from(content)
if (content.size) {
let before = this.contentMatch.fillBefore(content)
if (!before) return null
content = before.append(content)
}
let matched = this.contentMatch.matchFragment(content)
let after = matched && matched.fillBefore(Fragment.empty, true)
if (!after) return null
return new Node(this, attrs, (content as Fragment).append(after), Mark.setFrom(marks))
}
/// Returns true if the given fragment is valid content for this node
/// type with the given attributes.
validContent(content: Fragment) {
let result = this.contentMatch.matchFragment(content)
if (!result || !result.validEnd) return false
for (let i = 0; i < content.childCount; i++)
if (!this.allowsMarks(content.child(i).marks)) return false
return true
}
/// Throws a RangeError if the given fragment is not valid content for this
/// node type.
/// @internal
checkContent(content: Fragment) {
if (!this.validContent(content))
throw new RangeError(`Invalid content for node ${this.name}: ${content.toString().slice(0, 50)}`)
}
/// Check whether the given mark type is allowed in this node.
allowsMarkType(markType: MarkType) {
return this.markSet == null || this.markSet.indexOf(markType) > -1
}
/// Test whether the given set of marks are allowed in this node.
allowsMarks(marks: readonly Mark[]) {
if (this.markSet == null) return true
for (let i = 0; i < marks.length; i++) if (!this.allowsMarkType(marks[i].type)) return false
return true
}
/// Removes the marks that are not allowed in this node from the given set.
allowedMarks(marks: readonly Mark[]): readonly Mark[] {
if (this.markSet == null) return marks
let copy
for (let i = 0; i < marks.length; i++) {
if (!this.allowsMarkType(marks[i].type)) {
if (!copy) copy = marks.slice(0, i)
} else if (copy) {
copy.push(marks[i])
}
}
return !copy ? marks : copy.length ? copy : Mark.none
}
/// @internal
static compile<Nodes extends string>(nodes: OrderedMap<NodeSpec>, schema: Schema<Nodes>): {readonly [name in Nodes]: NodeType} {
let result = Object.create(null)
nodes.forEach((name, spec) => result[name] = new NodeType(name, schema, spec))
let topType = schema.spec.topNode || "doc"
if (!result[topType]) throw new RangeError("Schema is missing its top node type ('" + topType + "')")
if (!result.text) throw new RangeError("Every schema needs a 'text' type")
for (let _ in result.text.attrs) throw new RangeError("The text node type should not have attributes")
return result
}
}
// Attribute descriptors
class Attribute {
hasDefault: boolean
default: any
constructor(options: AttributeSpec) {
this.hasDefault = Object.prototype.hasOwnProperty.call(options, "default")
this.default = options.default
}
get isRequired() {
return !this.hasDefault
}
}
// Marks
/// Like nodes, marks (which are associated with nodes to signify
/// things like emphasis or being part of a link) are
/// [tagged](#model.Mark.type) with type objects, which are
/// instantiated once per `Schema`.
export class MarkType {
/// @internal
attrs: {[name: string]: Attribute}
/// @internal
excluded!: readonly MarkType[]
/// @internal
instance: Mark | null
/// @internal
constructor(
/// The name of the mark type.
readonly name: string,
/// @internal
readonly rank: number,
/// The schema that this mark type instance is part of.
readonly schema: Schema,
/// The spec on which the type is based.
readonly spec: MarkSpec
) {
this.attrs = initAttrs(spec.attrs)
;(this as any).excluded = null
let defaults = defaultAttrs(this.attrs)
this.instance = defaults ? new Mark(this, defaults) : null
}
/// Create a mark of this type. `attrs` may be `null` or an object
/// containing only some of the mark's attributes. The others, if
/// they have defaults, will be added.
create(attrs: Attrs | null = null) {
if (!attrs && this.instance) return this.instance
return new Mark(this, computeAttrs(this.attrs, attrs))
}
/// @internal
static compile(marks: OrderedMap<MarkSpec>, schema: Schema) {
let result = Object.create(null), rank = 0
marks.forEach((name, spec) => result[name] = new MarkType(name, rank++, schema, spec))
return result
}
/// When there is a mark of this type in the given set, a new set
/// without it is returned. Otherwise, the input set is returned.
removeFromSet(set: readonly Mark[]): readonly Mark[] {
for (var i = 0; i < set.length; i++) if (set[i].type == this) {
set = set.slice(0, i).concat(set.slice(i + 1))
i--
}
return set
}
/// Tests whether there is a mark of this type in the given set.
isInSet(set: readonly Mark[]): Mark | undefined {
for (let i = 0; i < set.length; i++)
if (set[i].type == this) return set[i]
}
/// Queries whether a given mark type is
/// [excluded](#model.MarkSpec.excludes) by this one.
excludes(other: MarkType) {
return this.excluded.indexOf(other) > -1
}
}
/// An object describing a schema, as passed to the [`Schema`](#model.Schema)
/// constructor.
export interface SchemaSpec<Nodes extends string = any, Marks extends string = any> {
/// The node types in this schema. Maps names to
/// [`NodeSpec`](#model.NodeSpec) objects that describe the node type
/// associated with that name. Their order is significant—it
/// determines which [parse rules](#model.NodeSpec.parseDOM) take
/// precedence by default, and which nodes come first in a given
/// [group](#model.NodeSpec.group).
nodes: {[name in Nodes]: NodeSpec} | OrderedMap<NodeSpec>,
/// The mark types that exist in this schema. The order in which they
/// are provided determines the order in which [mark
/// sets](#model.Mark.addToSet) are sorted and in which [parse
/// rules](#model.MarkSpec.parseDOM) are tried.
marks?: {[name in Marks]: MarkSpec} | OrderedMap<MarkSpec>
/// The name of the default top-level node for the schema. Defaults
/// to `"doc"`.
topNode?: string
}
/// A description of a node type, used when defining a schema.
export interface NodeSpec {
/// The content expression for this node, as described in the [schema
/// guide](/docs/guide/#schema.content_expressions). When not given,
/// the node does not allow any content.
content?: string
/// The marks that are allowed inside of this node. May be a
/// space-separated string referring to mark names or groups, `"_"`
/// to explicitly allow all marks, or `""` to disallow marks. When
/// not given, nodes with inline content default to allowing all
/// marks, other nodes default to not allowing marks.
marks?: string
/// The group or space-separated groups to which this node belongs,
/// which can be referred to in the content expressions for the
/// schema.
group?: string
/// Should be set to true for inline nodes. (Implied for text nodes.)
inline?: boolean
/// Can be set to true to indicate that, though this isn't a [leaf
/// node](#model.NodeType.isLeaf), it doesn't have directly editable
/// content and should be treated as a single unit in the view.
atom?: boolean
/// The attributes that nodes of this type get.
attrs?: {[name: string]: AttributeSpec}
/// Controls whether nodes of this type can be selected as a [node
/// selection](#state.NodeSelection). Defaults to true for non-text
/// nodes.
selectable?: boolean
/// Determines whether nodes of this type can be dragged without
/// being selected. Defaults to false.
draggable?: boolean
/// Can be used to indicate that this node contains code, which
/// causes some commands to behave differently.
code?: boolean
/// Controls way whitespace in this a node is parsed. The default is
/// `"normal"`, which causes the [DOM parser](#model.DOMParser) to
/// collapse whitespace in normal mode, and normalize it (replacing
/// newlines and such with spaces) otherwise. `"pre"` causes the
/// parser to preserve spaces inside the node. When this option isn't
/// given, but [`code`](#model.NodeSpec.code) is true, `whitespace`
/// will default to `"pre"`. Note that this option doesn't influence
/// the way the node is rendered—that should be handled by `toDOM`
/// and/or styling.
whitespace?: "pre" | "normal"
/// Determines whether this node is considered an important parent
/// node during replace operations (such as paste). Non-defining (the
/// default) nodes get dropped when their entire content is replaced,
/// whereas defining nodes persist and wrap the inserted content.
definingAsContext?: boolean
/// In inserted content the defining parents of the content are
/// preserved when possible. Typically, non-default-paragraph
/// textblock types, and possibly list items, are marked as defining.
definingForContent?: boolean
/// When enabled, enables both
/// [`definingAsContext`](#model.NodeSpec.definingAsContext) and
/// [`definingForContent`](#model.NodeSpec.definingForContent).
defining?: boolean
/// When enabled (default is false), the sides of nodes of this type
/// count as boundaries that regular editing operations, like
/// backspacing or lifting, won't cross. An example of a node that
/// should probably have this enabled is a table cell.
isolating?: boolean
/// Defines the default way a node of this type should be serialized
/// to DOM/HTML (as used by
/// [`DOMSerializer.fromSchema`](#model.DOMSerializer^fromSchema)).
/// Should return a DOM node or an [array
/// structure](#model.DOMOutputSpec) that describes one, with an
/// optional number zero (“hole”) in it to indicate where the node's
/// content should be inserted.
///
/// For text nodes, the default is to create a text DOM node. Though
/// it is possible to create a serializer where text is rendered
/// differently, this is not supported inside the editor, so you
/// shouldn't override that in your text node spec.
toDOM?: (node: Node) => DOMOutputSpec
/// Associates DOM parser information with this node, which can be
/// used by [`DOMParser.fromSchema`](#model.DOMParser^fromSchema) to
/// automatically derive a parser. The `node` field in the rules is
/// implied (the name of this node will be filled in automatically).
/// If you supply your own parser, you do not need to also specify
/// parsing rules in your schema.
parseDOM?: readonly TagParseRule[]
/// Defines the default way a node of this type should be serialized
/// to a string representation for debugging (e.g. in error messages).
toDebugString?: (node: Node) => string
/// Defines the default way a [leaf node](#model.NodeType.isLeaf) of
/// this type should be serialized to a string (as used by
/// [`Node.textBetween`](#model.Node^textBetween) and
/// [`Node.textContent`](#model.Node^textContent)).
leafText?: (node: Node) => string
/// A single inline node in a schema can be set to be a linebreak
/// equivalent. When converting between block types that support the
/// node and block types that don't but have
/// [`whitespace`](#model.NodeSpec.whitespace) set to `"pre"`,
/// [`setBlockType`](#transform.Transform.setBlockType) will convert
/// between newline characters to or from linebreak nodes as
/// appropriate.
linebreakReplacement?: boolean
/// Node specs may include arbitrary properties that can be read by
/// other code via [`NodeType.spec`](#model.NodeType.spec).
[key: string]: any
}
/// Used to define marks when creating a schema.
export interface MarkSpec {
/// The attributes that marks of this type get.
attrs?: {[name: string]: AttributeSpec}
/// Whether this mark should be active when the cursor is positioned
/// at its end (or at its start when that is also the start of the
/// parent node). Defaults to true.
inclusive?: boolean
/// Determines which other marks this mark can coexist with. Should
/// be a space-separated strings naming other marks or groups of marks.
/// When a mark is [added](#model.Mark.addToSet) to a set, all marks
/// that it excludes are removed in the process. If the set contains
/// any mark that excludes the new mark but is not, itself, excluded
/// by the new mark, the mark can not be added an the set. You can
/// use the value `"_"` to indicate that the mark excludes all
/// marks in the schema.
///
/// Defaults to only being exclusive with marks of the same type. You
/// can set it to an empty string (or any string not containing the
/// mark's own name) to allow multiple marks of a given type to
/// coexist (as long as they have different attributes).
excludes?: string
/// The group or space-separated groups to which this mark belongs.
group?: string
/// Determines whether marks of this type can span multiple adjacent
/// nodes when serialized to DOM/HTML. Defaults to true.
spanning?: boolean
/// Defines the default way marks of this type should be serialized
/// to DOM/HTML. When the resulting spec contains a hole, that is
/// where the marked content is placed. Otherwise, it is appended to
/// the top node.
toDOM?: (mark: Mark, inline: boolean) => DOMOutputSpec
/// Associates DOM parser information with this mark (see the
/// corresponding [node spec field](#model.NodeSpec.parseDOM)). The
/// `mark` field in the rules is implied.
parseDOM?: readonly ParseRule[]
/// Mark specs can include additional properties that can be
/// inspected through [`MarkType.spec`](#model.MarkType.spec) when
/// working with the mark.
[key: string]: any
}
/// Used to [define](#model.NodeSpec.attrs) attributes on nodes or
/// marks.
export interface AttributeSpec {
/// The default value for this attribute, to use when no explicit
/// value is provided. Attributes that have no default must be
/// provided whenever a node or mark of a type that has them is
/// created.
default?: any
}
/// A document schema. Holds [node](#model.NodeType) and [mark
/// type](#model.MarkType) objects for the nodes and marks that may
/// occur in conforming documents, and provides functionality for
/// creating and deserializing such documents.
///
/// When given, the type parameters provide the names of the nodes and
/// marks in this schema.
export class Schema<Nodes extends string = any, Marks extends string = any> {
/// The [spec](#model.SchemaSpec) on which the schema is based,
/// with the added guarantee that its `nodes` and `marks`
/// properties are
/// [`OrderedMap`](https://github.com/marijnh/orderedmap) instances
/// (not raw objects).
spec: {
nodes: OrderedMap<NodeSpec>,
marks: OrderedMap<MarkSpec>,
topNode?: string
}
/// An object mapping the schema's node names to node type objects.
nodes: {readonly [name in Nodes]: NodeType} & {readonly [key: string]: NodeType}
/// A map from mark names to mark type objects.
marks: {readonly [name in Marks]: MarkType} & {readonly [key: string]: MarkType}
/// The [linebreak
/// replacement](#model.NodeSpec.linebreakReplacement) node defined
/// in this schema, if any.
linebreakReplacement: NodeType | null = null
/// Construct a schema from a schema [specification](#model.SchemaSpec).
constructor(spec: SchemaSpec<Nodes, Marks>) {
let instanceSpec = this.spec = {} as any
for (let prop in spec) instanceSpec[prop] = (spec as any)[prop]
instanceSpec.nodes = OrderedMap.from(spec.nodes),
instanceSpec.marks = OrderedMap.from(spec.marks || {}),
this.nodes = NodeType.compile(this.spec.nodes, this)
this.marks = MarkType.compile(this.spec.marks, this)
let contentExprCache = Object.create(null)
for (let prop in this.nodes) {
if (prop in this.marks)
throw new RangeError(prop + " can not be both a node and a mark")
let type = this.nodes[prop], contentExpr = type.spec.content || "", markExpr = type.spec.marks
type.contentMatch = contentExprCache[contentExpr] ||
(contentExprCache[contentExpr] = ContentMatch.parse(contentExpr, this.nodes))
;(type as any).inlineContent = type.contentMatch.inlineContent
if (type.spec.linebreakReplacement) {
if (this.linebreakReplacement) throw new RangeError("Multiple linebreak nodes defined")
if (!type.isInline || !type.isLeaf) throw new RangeError("Linebreak replacement nodes must be inline leaf nodes")
this.linebreakReplacement = type
}
type.markSet = markExpr == "_" ? null :
markExpr ? gatherMarks(this, markExpr.split(" ")) :
markExpr == "" || !type.inlineContent ? [] : null
}
for (let prop in this.marks) {
let type = this.marks[prop], excl = type.spec.excludes
type.excluded = excl == null ? [type] : excl == "" ? [] : gatherMarks(this, excl.split(" "))
}
this.nodeFromJSON = this.nodeFromJSON.bind(this)
this.markFromJSON = this.markFromJSON.bind(this)
this.topNodeType = this.nodes[this.spec.topNode || "doc"]
this.cached.wrappings = Object.create(null)
}
/// The type of the [default top node](#model.SchemaSpec.topNode)
/// for this schema.
topNodeType: NodeType
/// An object for storing whatever values modules may want to
/// compute and cache per schema. (If you want to store something
/// in it, try to use property names unlikely to clash.)
cached: {[key: string]: any} = Object.create(null)
/// Create a node in this schema. The `type` may be a string or a
/// `NodeType` instance. Attributes will be extended with defaults,
/// `content` may be a `Fragment`, `null`, a `Node`, or an array of
/// nodes.
node(type: string | NodeType,
attrs: Attrs | null = null,
content?: Fragment | Node | readonly Node[],
marks?: readonly Mark[]) {
if (typeof type == "string")
type = this.nodeType(type)
else if (!(type instanceof NodeType))
throw new RangeError("Invalid node type: " + type)
else if (type.schema != this)
throw new RangeError("Node type from different schema used (" + type.name + ")")
return type.createChecked(attrs, content, marks)
}
/// Create a text node in the schema. Empty text nodes are not
/// allowed.
text(text: string, marks?: readonly Mark[] | null): Node {
let type = this.nodes.text
return new TextNode(type, type.defaultAttrs, text, Mark.setFrom(marks))
}
/// Create a mark with the given type and attributes.
mark(type: string | MarkType, attrs?: Attrs | null) {
if (typeof type == "string") type = this.marks[type]
return type.create(attrs)
}
/// Deserialize a node from its JSON representation. This method is
/// bound.
nodeFromJSON(json: any): Node {
return Node.fromJSON(this, json)
}
/// Deserialize a mark from its JSON representation. This method is
/// bound.
markFromJSON(json: any): Mark {
return Mark.fromJSON(this, json)
}
/// @internal
nodeType(name: string) {
let found = this.nodes[name]
if (!found) throw new RangeError("Unknown node type: " + name)
return found
}
}
function gatherMarks(schema: Schema, marks: readonly string[]) {
let found = []
for (let i = 0; i < marks.length; i++) {
let name = marks[i], mark = schema.marks[name], ok = mark
if (mark) {
found.push(mark)
} else {
for (let prop in schema.marks) {
let mark = schema.marks[prop]
if (name == "_" || (mark.spec.group && mark.spec.group.split(" ").indexOf(name) > -1))
found.push(ok = mark)
}
}
if (!ok) throw new SyntaxError("Unknown mark type: '" + marks[i] + "'")
}
return found
}

View File

@@ -0,0 +1,190 @@
import {Fragment} from "./fragment"
import {Node} from "./node"
import {Schema, NodeType, MarkType} from "./schema"
import {Mark} from "./mark"
import {DOMNode} from "./dom"
/// A description of a DOM structure. Can be either a string, which is
/// interpreted as a text node, a DOM node, which is interpreted as
/// itself, a `{dom, contentDOM}` object, or an array.
///
/// An array describes a DOM element. The first value in the array
/// should be a string—the name of the DOM element, optionally prefixed
/// by a namespace URL and a space. If the second element is plain
/// object, it is interpreted as a set of attributes for the element.
/// Any elements after that (including the 2nd if it's not an attribute
/// object) are interpreted as children of the DOM elements, and must
/// either be valid `DOMOutputSpec` values, or the number zero.
///
/// The number zero (pronounced “hole”) is used to indicate the place
/// where a node's child nodes should be inserted. If it occurs in an
/// output spec, it should be the only child element in its parent
/// node.
export type DOMOutputSpec = string | DOMNode | {dom: DOMNode, contentDOM?: HTMLElement} | readonly [string, ...any[]]
/// A DOM serializer knows how to convert ProseMirror nodes and
/// marks of various types to DOM nodes.
export class DOMSerializer {
/// Create a serializer. `nodes` should map node names to functions
/// that take a node and return a description of the corresponding
/// DOM. `marks` does the same for mark names, but also gets an
/// argument that tells it whether the mark's content is block or
/// inline content (for typical use, it'll always be inline). A mark
/// serializer may be `null` to indicate that marks of that type
/// should not be serialized.
constructor(
/// The node serialization functions.
readonly nodes: {[node: string]: (node: Node) => DOMOutputSpec},
/// The mark serialization functions.
readonly marks: {[mark: string]: (mark: Mark, inline: boolean) => DOMOutputSpec}
) {}
/// Serialize the content of this fragment to a DOM fragment. When
/// not in the browser, the `document` option, containing a DOM
/// document, should be passed so that the serializer can create
/// nodes.
serializeFragment(fragment: Fragment, options: {document?: Document} = {}, target?: HTMLElement | DocumentFragment) {
if (!target) target = doc(options).createDocumentFragment()
let top = target!, active: [Mark, HTMLElement | DocumentFragment][] = []
fragment.forEach(node => {
if (active.length || node.marks.length) {
let keep = 0, rendered = 0
while (keep < active.length && rendered < node.marks.length) {
let next = node.marks[rendered]
if (!this.marks[next.type.name]) { rendered++; continue }
if (!next.eq(active[keep][0]) || next.type.spec.spanning === false) break
keep++; rendered++
}
while (keep < active.length) top = active.pop()![1]
while (rendered < node.marks.length) {
let add = node.marks[rendered++]
let markDOM = this.serializeMark(add, node.isInline, options)
if (markDOM) {
active.push([add, top])
top.appendChild(markDOM.dom)
top = markDOM.contentDOM || markDOM.dom as HTMLElement
}
}
}
top.appendChild(this.serializeNodeInner(node, options))
})
return target
}
/// @internal
serializeNodeInner(node: Node, options: {document?: Document}) {
let {dom, contentDOM} =
DOMSerializer.renderSpec(doc(options), this.nodes[node.type.name](node))
if (contentDOM) {
if (node.isLeaf)
throw new RangeError("Content hole not allowed in a leaf node spec")
this.serializeFragment(node.content, options, contentDOM)
}
return dom
}
/// Serialize this node to a DOM node. This can be useful when you
/// need to serialize a part of a document, as opposed to the whole
/// document. To serialize a whole document, use
/// [`serializeFragment`](#model.DOMSerializer.serializeFragment) on
/// its [content](#model.Node.content).
serializeNode(node: Node, options: {document?: Document} = {}) {
let dom = this.serializeNodeInner(node, options)
for (let i = node.marks.length - 1; i >= 0; i--) {
let wrap = this.serializeMark(node.marks[i], node.isInline, options)
if (wrap) {
;(wrap.contentDOM || wrap.dom).appendChild(dom)
dom = wrap.dom
}
}
return dom
}
/// @internal
serializeMark(mark: Mark, inline: boolean, options: {document?: Document} = {}) {
let toDOM = this.marks[mark.type.name]
return toDOM && DOMSerializer.renderSpec(doc(options), toDOM(mark, inline))
}
/// Render an [output spec](#model.DOMOutputSpec) to a DOM node. If
/// the spec has a hole (zero) in it, `contentDOM` will point at the
/// node with the hole.
static renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null = null): {
dom: DOMNode,
contentDOM?: HTMLElement
} {
if (typeof structure == "string")
return {dom: doc.createTextNode(structure)}
if ((structure as DOMNode).nodeType != null)
return {dom: structure as DOMNode}
if ((structure as any).dom && (structure as any).dom.nodeType != null)
return structure as {dom: DOMNode, contentDOM?: HTMLElement}
let tagName = (structure as [string])[0], space = tagName.indexOf(" ")
if (space > 0) {
xmlNS = tagName.slice(0, space)
tagName = tagName.slice(space + 1)
}
let contentDOM: HTMLElement | undefined
let dom = (xmlNS ? doc.createElementNS(xmlNS, tagName) : doc.createElement(tagName)) as HTMLElement
let attrs = (structure as any)[1], start = 1
if (attrs && typeof attrs == "object" && attrs.nodeType == null && !Array.isArray(attrs)) {
start = 2
for (let name in attrs) if (attrs[name] != null) {
let space = name.indexOf(" ")
if (space > 0) dom.setAttributeNS(name.slice(0, space), name.slice(space + 1), attrs[name])
else dom.setAttribute(name, attrs[name])
}
}
for (let i = start; i < (structure as readonly any[]).length; i++) {
let child = (structure as any)[i] as DOMOutputSpec | 0
if (child === 0) {
if (i < (structure as readonly any[]).length - 1 || i > start)
throw new RangeError("Content hole must be the only child of its parent node")
return {dom, contentDOM: dom}
} else {
let {dom: inner, contentDOM: innerContent} = DOMSerializer.renderSpec(doc, child, xmlNS)
dom.appendChild(inner)
if (innerContent) {
if (contentDOM) throw new RangeError("Multiple content holes")
contentDOM = innerContent as HTMLElement
}
}
}
return {dom, contentDOM}
}
/// Build a serializer using the [`toDOM`](#model.NodeSpec.toDOM)
/// properties in a schema's node and mark specs.
static fromSchema(schema: Schema): DOMSerializer {
return schema.cached.domSerializer as DOMSerializer ||
(schema.cached.domSerializer = new DOMSerializer(this.nodesFromSchema(schema), this.marksFromSchema(schema)))
}
/// Gather the serializers in a schema's node specs into an object.
/// This can be useful as a base to build a custom serializer from.
static nodesFromSchema(schema: Schema) {
let result = gatherToDOM(schema.nodes)
if (!result.text) result.text = node => node.text
return result as {[node: string]: (node: Node) => DOMOutputSpec}
}
/// Gather the serializers in a schema's mark specs into an object.
static marksFromSchema(schema: Schema) {
return gatherToDOM(schema.marks) as {[mark: string]: (mark: Mark, inline: boolean) => DOMOutputSpec}
}
}
function gatherToDOM(obj: {[node: string]: NodeType | MarkType}) {
let result: {[node: string]: (value: any, inline: boolean) => DOMOutputSpec} = {}
for (let name in obj) {
let toDOM = obj[name].spec.toDOM
if (toDOM) result[name] = toDOM
}
return result
}
function doc(options: {document?: Document}) {
return options.document || window.document
}