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,59 @@
This module defines a way of modifying documents that allows changes
to be recorded, replayed, and reordered. You can read more about
transformations in [the guide](/docs/guide/#transform).
### Steps
Transforming happens in `Step`s, which are atomic, well-defined
modifications to a document. [Applying](#transform.Step.apply) a step
produces a new document.
Each step provides a [change map](#transform.StepMap) that maps
positions in the old document to position in the transformed document.
Steps can be [inverted](#transform.Step.invert) to create a step that
undoes their effect, and chained together in a convenience object
called a [`Transform`](#transform.Transform).
@Step
@StepResult
@ReplaceStep
@ReplaceAroundStep
@AddMarkStep
@RemoveMarkStep
@AddNodeMarkStep
@RemoveNodeMarkStep
@AttrStep
@DocAttrStep
### Position Mapping
Mapping positions from one document to another by running through the
[step maps](#transform.StepMap) produced by steps is an important
operation in ProseMirror. It is used, for example, for updating the
selection when the document changes.
@Mappable
@MapResult
@StepMap
@Mapping
### Document transforms
Because you often need to collect a number of steps together to effect
a composite change, ProseMirror provides an abstraction to make this
easy. [State transactions](#state.Transaction) are a subclass of
transforms.
@Transform
The following helper functions can be useful when creating
transformations or determining whether they are even possible.
@replaceStep
@liftTarget
@findWrapping
@canSplit
@canJoin
@joinPoint
@insertPoint
@dropPoint

View File

@@ -0,0 +1,98 @@
import {Fragment, Slice, Node, Schema} from "prosemirror-model"
import {Step, StepResult} from "./step"
import {StepMap, Mappable} from "./map"
/// Update an attribute in a specific node.
export class AttrStep extends Step {
/// Construct an attribute step.
constructor(
/// The position of the target node.
readonly pos: number,
/// The attribute to set.
readonly attr: string,
// The attribute's new value.
readonly value: any
) {
super()
}
apply(doc: Node) {
let node = doc.nodeAt(this.pos)
if (!node) return StepResult.fail("No node at attribute step's position")
let attrs = Object.create(null)
for (let name in node.attrs) attrs[name] = node.attrs[name]
attrs[this.attr] = this.value
let updated = node.type.create(attrs, null, node.marks)
return StepResult.fromReplace(doc, this.pos, this.pos + 1, new Slice(Fragment.from(updated), 0, node.isLeaf ? 0 : 1))
}
getMap() {
return StepMap.empty
}
invert(doc: Node) {
return new AttrStep(this.pos, this.attr, doc.nodeAt(this.pos)!.attrs[this.attr])
}
map(mapping: Mappable) {
let pos = mapping.mapResult(this.pos, 1)
return pos.deletedAfter ? null : new AttrStep(pos.pos, this.attr, this.value)
}
toJSON(): any {
return {stepType: "attr", pos: this.pos, attr: this.attr, value: this.value}
}
static fromJSON(schema: Schema, json: any) {
if (typeof json.pos != "number" || typeof json.attr != "string")
throw new RangeError("Invalid input for AttrStep.fromJSON")
return new AttrStep(json.pos, json.attr, json.value)
}
}
Step.jsonID("attr", AttrStep)
/// Update an attribute in the doc node.
export class DocAttrStep extends Step {
/// Construct an attribute step.
constructor(
/// The attribute to set.
readonly attr: string,
// The attribute's new value.
readonly value: any
) {
super()
}
apply(doc: Node) {
let attrs = Object.create(null)
for (let name in doc.attrs) attrs[name] = doc.attrs[name]
attrs[this.attr] = this.value
let updated = doc.type.create(attrs, doc.content, doc.marks)
return StepResult.ok(updated)
}
getMap() {
return StepMap.empty
}
invert(doc: Node) {
return new DocAttrStep(this.attr, doc.attrs[this.attr])
}
map(mapping: Mappable) {
return this
}
toJSON(): any {
return {stepType: "docAttr", attr: this.attr, value: this.value}
}
static fromJSON(schema: Schema, json: any) {
if (typeof json.attr != "string")
throw new RangeError("Invalid input for DocAttrStep.fromJSON")
return new DocAttrStep(json.attr, json.value)
}
}
Step.jsonID("docAttr", DocAttrStep)

View File

@@ -0,0 +1,11 @@
export {Transform} from "./transform"
/// @internal
export {TransformError} from "./transform"
export {Step, StepResult} from "./step"
export {joinPoint, canJoin, canSplit, insertPoint, dropPoint, liftTarget, findWrapping} from "./structure"
export {StepMap, MapResult, Mapping, Mappable} from "./map"
export {AddMarkStep, RemoveMarkStep, AddNodeMarkStep, RemoveNodeMarkStep} from "./mark_step"
export {ReplaceStep, ReplaceAroundStep} from "./replace_step"
export {AttrStep, DocAttrStep} from "./attr_step"
import "./mark"
export {replaceStep} from "./replace"

View File

@@ -0,0 +1,275 @@
/// There are several things that positions can be mapped through.
/// Such objects conform to this interface.
export interface Mappable {
/// Map a position through this object. When given, `assoc` (should
/// be -1 or 1, defaults to 1) determines with which side the
/// position is associated, which determines in which direction to
/// move when a chunk of content is inserted at the mapped position.
map: (pos: number, assoc?: number) => number
/// Map a position, and return an object containing additional
/// information about the mapping. The result's `deleted` field tells
/// you whether the position was deleted (completely enclosed in a
/// replaced range) during the mapping. When content on only one side
/// is deleted, the position itself is only considered deleted when
/// `assoc` points in the direction of the deleted content.
mapResult: (pos: number, assoc?: number) => MapResult
}
// Recovery values encode a range index and an offset. They are
// represented as numbers, because tons of them will be created when
// mapping, for example, a large number of decorations. The number's
// lower 16 bits provide the index, the remaining bits the offset.
//
// Note: We intentionally don't use bit shift operators to en- and
// decode these, since those clip to 32 bits, which we might in rare
// cases want to overflow. A 64-bit float can represent 48-bit
// integers precisely.
const lower16 = 0xffff
const factor16 = Math.pow(2, 16)
function makeRecover(index: number, offset: number) { return index + offset * factor16 }
function recoverIndex(value: number) { return value & lower16 }
function recoverOffset(value: number) { return (value - (value & lower16)) / factor16 }
const DEL_BEFORE = 1, DEL_AFTER = 2, DEL_ACROSS = 4, DEL_SIDE = 8
/// An object representing a mapped position with extra
/// information.
export class MapResult {
/// @internal
constructor(
/// The mapped version of the position.
readonly pos: number,
/// @internal
readonly delInfo: number,
/// @internal
readonly recover: number | null
) {}
/// Tells you whether the position was deleted, that is, whether the
/// step removed the token on the side queried (via the `assoc`)
/// argument from the document.
get deleted() { return (this.delInfo & DEL_SIDE) > 0 }
/// Tells you whether the token before the mapped position was deleted.
get deletedBefore() { return (this.delInfo & (DEL_BEFORE | DEL_ACROSS)) > 0 }
/// True when the token after the mapped position was deleted.
get deletedAfter() { return (this.delInfo & (DEL_AFTER | DEL_ACROSS)) > 0 }
/// Tells whether any of the steps mapped through deletes across the
/// position (including both the token before and after the
/// position).
get deletedAcross() { return (this.delInfo & DEL_ACROSS) > 0 }
}
/// A map describing the deletions and insertions made by a step, which
/// can be used to find the correspondence between positions in the
/// pre-step version of a document and the same position in the
/// post-step version.
export class StepMap implements Mappable {
/// Create a position map. The modifications to the document are
/// represented as an array of numbers, in which each group of three
/// represents a modified chunk as `[start, oldSize, newSize]`.
constructor(
/// @internal
readonly ranges: readonly number[],
/// @internal
readonly inverted = false
) {
if (!ranges.length && StepMap.empty) return StepMap.empty
}
/// @internal
recover(value: number) {
let diff = 0, index = recoverIndex(value)
if (!this.inverted) for (let i = 0; i < index; i++)
diff += this.ranges[i * 3 + 2] - this.ranges[i * 3 + 1]
return this.ranges[index * 3] + diff + recoverOffset(value)
}
mapResult(pos: number, assoc = 1): MapResult { return this._map(pos, assoc, false) as MapResult }
map(pos: number, assoc = 1): number { return this._map(pos, assoc, true) as number }
/// @internal
_map(pos: number, assoc: number, simple: boolean) {
let diff = 0, oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2
for (let i = 0; i < this.ranges.length; i += 3) {
let start = this.ranges[i] - (this.inverted ? diff : 0)
if (start > pos) break
let oldSize = this.ranges[i + oldIndex], newSize = this.ranges[i + newIndex], end = start + oldSize
if (pos <= end) {
let side = !oldSize ? assoc : pos == start ? -1 : pos == end ? 1 : assoc
let result = start + diff + (side < 0 ? 0 : newSize)
if (simple) return result
let recover = pos == (assoc < 0 ? start : end) ? null : makeRecover(i / 3, pos - start)
let del = pos == start ? DEL_AFTER : pos == end ? DEL_BEFORE : DEL_ACROSS
if (assoc < 0 ? pos != start : pos != end) del |= DEL_SIDE
return new MapResult(result, del, recover)
}
diff += newSize - oldSize
}
return simple ? pos + diff : new MapResult(pos + diff, 0, null)
}
/// @internal
touches(pos: number, recover: number) {
let diff = 0, index = recoverIndex(recover)
let oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2
for (let i = 0; i < this.ranges.length; i += 3) {
let start = this.ranges[i] - (this.inverted ? diff : 0)
if (start > pos) break
let oldSize = this.ranges[i + oldIndex], end = start + oldSize
if (pos <= end && i == index * 3) return true
diff += this.ranges[i + newIndex] - oldSize
}
return false
}
/// Calls the given function on each of the changed ranges included in
/// this map.
forEach(f: (oldStart: number, oldEnd: number, newStart: number, newEnd: number) => void) {
let oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2
for (let i = 0, diff = 0; i < this.ranges.length; i += 3) {
let start = this.ranges[i], oldStart = start - (this.inverted ? diff : 0), newStart = start + (this.inverted ? 0 : diff)
let oldSize = this.ranges[i + oldIndex], newSize = this.ranges[i + newIndex]
f(oldStart, oldStart + oldSize, newStart, newStart + newSize)
diff += newSize - oldSize
}
}
/// Create an inverted version of this map. The result can be used to
/// map positions in the post-step document to the pre-step document.
invert() {
return new StepMap(this.ranges, !this.inverted)
}
/// @internal
toString() {
return (this.inverted ? "-" : "") + JSON.stringify(this.ranges)
}
/// Create a map that moves all positions by offset `n` (which may be
/// negative). This can be useful when applying steps meant for a
/// sub-document to a larger document, or vice-versa.
static offset(n: number) {
return n == 0 ? StepMap.empty : new StepMap(n < 0 ? [0, -n, 0] : [0, 0, n])
}
/// A StepMap that contains no changed ranges.
static empty = new StepMap([])
}
/// A mapping represents a pipeline of zero or more [step
/// maps](#transform.StepMap). It has special provisions for losslessly
/// handling mapping positions through a series of steps in which some
/// steps are inverted versions of earlier steps. (This comes up when
/// [rebasing](/docs/guide/#transform.rebasing) steps for
/// collaboration or history management.)
export class Mapping implements Mappable {
/// Create a new mapping with the given position maps.
constructor(
/// The step maps in this mapping.
readonly maps: StepMap[] = [],
/// @internal
public mirror?: number[],
/// The starting position in the `maps` array, used when `map` or
/// `mapResult` is called.
public from = 0,
/// The end position in the `maps` array.
public to = maps.length
) {}
/// Create a mapping that maps only through a part of this one.
slice(from = 0, to = this.maps.length) {
return new Mapping(this.maps, this.mirror, from, to)
}
/// @internal
copy() {
return new Mapping(this.maps.slice(), this.mirror && this.mirror.slice(), this.from, this.to)
}
/// Add a step map to the end of this mapping. If `mirrors` is
/// given, it should be the index of the step map that is the mirror
/// image of this one.
appendMap(map: StepMap, mirrors?: number) {
this.to = this.maps.push(map)
if (mirrors != null) this.setMirror(this.maps.length - 1, mirrors)
}
/// Add all the step maps in a given mapping to this one (preserving
/// mirroring information).
appendMapping(mapping: Mapping) {
for (let i = 0, startSize = this.maps.length; i < mapping.maps.length; i++) {
let mirr = mapping.getMirror(i)
this.appendMap(mapping.maps[i], mirr != null && mirr < i ? startSize + mirr : undefined)
}
}
/// Finds the offset of the step map that mirrors the map at the
/// given offset, in this mapping (as per the second argument to
/// `appendMap`).
getMirror(n: number): number | undefined {
if (this.mirror) for (let i = 0; i < this.mirror.length; i++)
if (this.mirror[i] == n) return this.mirror[i + (i % 2 ? -1 : 1)]
}
/// @internal
setMirror(n: number, m: number) {
if (!this.mirror) this.mirror = []
this.mirror.push(n, m)
}
/// Append the inverse of the given mapping to this one.
appendMappingInverted(mapping: Mapping) {
for (let i = mapping.maps.length - 1, totalSize = this.maps.length + mapping.maps.length; i >= 0; i--) {
let mirr = mapping.getMirror(i)
this.appendMap(mapping.maps[i].invert(), mirr != null && mirr > i ? totalSize - mirr - 1 : undefined)
}
}
/// Create an inverted version of this mapping.
invert() {
let inverse = new Mapping
inverse.appendMappingInverted(this)
return inverse
}
/// Map a position through this mapping.
map(pos: number, assoc = 1) {
if (this.mirror) return this._map(pos, assoc, true) as number
for (let i = this.from; i < this.to; i++)
pos = this.maps[i].map(pos, assoc)
return pos
}
/// Map a position through this mapping, returning a mapping
/// result.
mapResult(pos: number, assoc = 1) { return this._map(pos, assoc, false) as MapResult }
/// @internal
_map(pos: number, assoc: number, simple: boolean) {
let delInfo = 0
for (let i = this.from; i < this.to; i++) {
let map = this.maps[i], result = map.mapResult(pos, assoc)
if (result.recover != null) {
let corr = this.getMirror(i)
if (corr != null && corr > i && corr < this.to) {
i = corr
pos = this.maps[corr].recover(result.recover)
continue
}
}
delInfo |= result.delInfo
pos = result.pos
}
return simple ? pos : new MapResult(pos, delInfo, null)
}
}

View File

@@ -0,0 +1,106 @@
import {Mark, MarkType, Slice, Fragment, NodeType} from "prosemirror-model"
import {Step} from "./step"
import {Transform} from "./transform"
import {AddMarkStep, RemoveMarkStep} from "./mark_step"
import {ReplaceStep} from "./replace_step"
export function addMark(tr: Transform, from: number, to: number, mark: Mark) {
let removed: Step[] = [], added: Step[] = []
let removing: RemoveMarkStep | undefined, adding: AddMarkStep | undefined
tr.doc.nodesBetween(from, to, (node, pos, parent) => {
if (!node.isInline) return
let marks = node.marks
if (!mark.isInSet(marks) && parent!.type.allowsMarkType(mark.type)) {
let start = Math.max(pos, from), end = Math.min(pos + node.nodeSize, to)
let newSet = mark.addToSet(marks)
for (let i = 0; i < marks.length; i++) {
if (!marks[i].isInSet(newSet)) {
if (removing && removing.to == start && removing.mark.eq(marks[i]))
(removing as any).to = end
else
removed.push(removing = new RemoveMarkStep(start, end, marks[i]))
}
}
if (adding && adding.to == start)
(adding as any).to = end
else
added.push(adding = new AddMarkStep(start, end, mark))
}
})
removed.forEach(s => tr.step(s))
added.forEach(s => tr.step(s))
}
export function removeMark(tr: Transform, from: number, to: number, mark?: Mark | MarkType | null) {
let matched: {style: Mark, from: number, to: number, step: number}[] = [], step = 0
tr.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isInline) return
step++
let toRemove = null
if (mark instanceof MarkType) {
let set = node.marks, found
while (found = mark.isInSet(set)) {
;(toRemove || (toRemove = [])).push(found)
set = found.removeFromSet(set)
}
} else if (mark) {
if (mark.isInSet(node.marks)) toRemove = [mark]
} else {
toRemove = node.marks
}
if (toRemove && toRemove.length) {
let end = Math.min(pos + node.nodeSize, to)
for (let i = 0; i < toRemove.length; i++) {
let style = toRemove[i], found
for (let j = 0; j < matched.length; j++) {
let m = matched[j]
if (m.step == step - 1 && style.eq(matched[j].style)) found = m
}
if (found) {
found.to = end
found.step = step
} else {
matched.push({style, from: Math.max(pos, from), to: end, step})
}
}
}
})
matched.forEach(m => tr.step(new RemoveMarkStep(m.from, m.to, m.style)))
}
export function clearIncompatible(tr: Transform, pos: number, parentType: NodeType,
match = parentType.contentMatch,
clearNewlines = true) {
let node = tr.doc.nodeAt(pos)!
let replSteps: Step[] = [], cur = pos + 1
for (let i = 0; i < node.childCount; i++) {
let child = node.child(i), end = cur + child.nodeSize
let allowed = match.matchType(child.type)
if (!allowed) {
replSteps.push(new ReplaceStep(cur, end, Slice.empty))
} else {
match = allowed
for (let j = 0; j < child.marks.length; j++) if (!parentType.allowsMarkType(child.marks[j].type))
tr.step(new RemoveMarkStep(cur, end, child.marks[j]))
if (clearNewlines && child.isText && parentType.whitespace != "pre") {
let m, newline = /\r?\n|\r/g, slice
while (m = newline.exec(child.text!)) {
if (!slice) slice = new Slice(Fragment.from(parentType.schema.text(" ", parentType.allowedMarks(child.marks))),
0, 0)
replSteps.push(new ReplaceStep(cur + m.index, cur + m.index + m[0].length, slice))
}
}
}
cur = end
}
if (!match.validEnd) {
let fill = match.fillBefore(Fragment.empty, true)
tr.replace(cur, cur, new Slice(fill!, 0, 0))
}
for (let i = replSteps.length - 1; i >= 0; i--) tr.step(replSteps[i])
}

View File

@@ -0,0 +1,224 @@
import {Fragment, Slice, Node, Mark, Schema} from "prosemirror-model"
import {Step, StepResult} from "./step"
import {Mappable} from "./map"
function mapFragment(fragment: Fragment, f: (child: Node, parent: Node, i: number) => Node, parent: Node): Fragment {
let mapped = []
for (let i = 0; i < fragment.childCount; i++) {
let child = fragment.child(i)
if (child.content.size) child = child.copy(mapFragment(child.content, f, child))
if (child.isInline) child = f(child, parent, i)
mapped.push(child)
}
return Fragment.fromArray(mapped)
}
/// Add a mark to all inline content between two positions.
export class AddMarkStep extends Step {
/// Create a mark step.
constructor(
/// The start of the marked range.
readonly from: number,
/// The end of the marked range.
readonly to: number,
/// The mark to add.
readonly mark: Mark
) {
super()
}
apply(doc: Node) {
let oldSlice = doc.slice(this.from, this.to), $from = doc.resolve(this.from)
let parent = $from.node($from.sharedDepth(this.to))
let slice = new Slice(mapFragment(oldSlice.content, (node, parent) => {
if (!node.isAtom || !parent.type.allowsMarkType(this.mark.type)) return node
return node.mark(this.mark.addToSet(node.marks))
}, parent), oldSlice.openStart, oldSlice.openEnd)
return StepResult.fromReplace(doc, this.from, this.to, slice)
}
invert(): Step {
return new RemoveMarkStep(this.from, this.to, this.mark)
}
map(mapping: Mappable): Step | null {
let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1)
if (from.deleted && to.deleted || from.pos >= to.pos) return null
return new AddMarkStep(from.pos, to.pos, this.mark)
}
merge(other: Step): Step | null {
if (other instanceof AddMarkStep &&
other.mark.eq(this.mark) &&
this.from <= other.to && this.to >= other.from)
return new AddMarkStep(Math.min(this.from, other.from),
Math.max(this.to, other.to), this.mark)
return null
}
toJSON(): any {
return {stepType: "addMark", mark: this.mark.toJSON(),
from: this.from, to: this.to}
}
/// @internal
static fromJSON(schema: Schema, json: any) {
if (typeof json.from != "number" || typeof json.to != "number")
throw new RangeError("Invalid input for AddMarkStep.fromJSON")
return new AddMarkStep(json.from, json.to, schema.markFromJSON(json.mark))
}
}
Step.jsonID("addMark", AddMarkStep)
/// Remove a mark from all inline content between two positions.
export class RemoveMarkStep extends Step {
/// Create a mark-removing step.
constructor(
/// The start of the unmarked range.
readonly from: number,
/// The end of the unmarked range.
readonly to: number,
/// The mark to remove.
readonly mark: Mark
) {
super()
}
apply(doc: Node) {
let oldSlice = doc.slice(this.from, this.to)
let slice = new Slice(mapFragment(oldSlice.content, node => {
return node.mark(this.mark.removeFromSet(node.marks))
}, doc), oldSlice.openStart, oldSlice.openEnd)
return StepResult.fromReplace(doc, this.from, this.to, slice)
}
invert(): Step {
return new AddMarkStep(this.from, this.to, this.mark)
}
map(mapping: Mappable): Step | null {
let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1)
if (from.deleted && to.deleted || from.pos >= to.pos) return null
return new RemoveMarkStep(from.pos, to.pos, this.mark)
}
merge(other: Step): Step | null {
if (other instanceof RemoveMarkStep &&
other.mark.eq(this.mark) &&
this.from <= other.to && this.to >= other.from)
return new RemoveMarkStep(Math.min(this.from, other.from),
Math.max(this.to, other.to), this.mark)
return null
}
toJSON(): any {
return {stepType: "removeMark", mark: this.mark.toJSON(),
from: this.from, to: this.to}
}
/// @internal
static fromJSON(schema: Schema, json: any) {
if (typeof json.from != "number" || typeof json.to != "number")
throw new RangeError("Invalid input for RemoveMarkStep.fromJSON")
return new RemoveMarkStep(json.from, json.to, schema.markFromJSON(json.mark))
}
}
Step.jsonID("removeMark", RemoveMarkStep)
/// Add a mark to a specific node.
export class AddNodeMarkStep extends Step {
/// Create a node mark step.
constructor(
/// The position of the target node.
readonly pos: number,
/// The mark to add.
readonly mark: Mark
) {
super()
}
apply(doc: Node) {
let node = doc.nodeAt(this.pos)
if (!node) return StepResult.fail("No node at mark step's position")
let updated = node.type.create(node.attrs, null, this.mark.addToSet(node.marks))
return StepResult.fromReplace(doc, this.pos, this.pos + 1, new Slice(Fragment.from(updated), 0, node.isLeaf ? 0 : 1))
}
invert(doc: Node): Step {
let node = doc.nodeAt(this.pos)
if (node) {
let newSet = this.mark.addToSet(node.marks)
if (newSet.length == node.marks.length) {
for (let i = 0; i < node.marks.length; i++)
if (!node.marks[i].isInSet(newSet))
return new AddNodeMarkStep(this.pos, node.marks[i])
return new AddNodeMarkStep(this.pos, this.mark)
}
}
return new RemoveNodeMarkStep(this.pos, this.mark)
}
map(mapping: Mappable): Step | null {
let pos = mapping.mapResult(this.pos, 1)
return pos.deletedAfter ? null : new AddNodeMarkStep(pos.pos, this.mark)
}
toJSON(): any {
return {stepType: "addNodeMark", pos: this.pos, mark: this.mark.toJSON()}
}
/// @internal
static fromJSON(schema: Schema, json: any) {
if (typeof json.pos != "number")
throw new RangeError("Invalid input for AddNodeMarkStep.fromJSON")
return new AddNodeMarkStep(json.pos, schema.markFromJSON(json.mark))
}
}
Step.jsonID("addNodeMark", AddNodeMarkStep)
/// Remove a mark from a specific node.
export class RemoveNodeMarkStep extends Step {
/// Create a mark-removing step.
constructor(
/// The position of the target node.
readonly pos: number,
/// The mark to remove.
readonly mark: Mark
) {
super()
}
apply(doc: Node) {
let node = doc.nodeAt(this.pos)
if (!node) return StepResult.fail("No node at mark step's position")
let updated = node.type.create(node.attrs, null, this.mark.removeFromSet(node.marks))
return StepResult.fromReplace(doc, this.pos, this.pos + 1, new Slice(Fragment.from(updated), 0, node.isLeaf ? 0 : 1))
}
invert(doc: Node): Step {
let node = doc.nodeAt(this.pos)
if (!node || !this.mark.isInSet(node.marks)) return this
return new AddNodeMarkStep(this.pos, this.mark)
}
map(mapping: Mappable): Step | null {
let pos = mapping.mapResult(this.pos, 1)
return pos.deletedAfter ? null : new RemoveNodeMarkStep(pos.pos, this.mark)
}
toJSON(): any {
return {stepType: "removeNodeMark", pos: this.pos, mark: this.mark.toJSON()}
}
/// @internal
static fromJSON(schema: Schema, json: any) {
if (typeof json.pos != "number")
throw new RangeError("Invalid input for RemoveNodeMarkStep.fromJSON")
return new RemoveNodeMarkStep(json.pos, schema.markFromJSON(json.mark))
}
}
Step.jsonID("removeNodeMark", RemoveNodeMarkStep)

View File

@@ -0,0 +1,459 @@
import {Fragment, Slice, Node, ResolvedPos, NodeType, ContentMatch, Attrs} from "prosemirror-model"
import {Step} from "./step"
import {ReplaceStep, ReplaceAroundStep} from "./replace_step"
import {Transform} from "./transform"
import {insertPoint} from "./structure"
/// Fit a slice into a given position in the document, producing a
/// [step](#transform.Step) that inserts it. Will return null if
/// there's no meaningful way to insert the slice here, or inserting it
/// would be a no-op (an empty slice over an empty range).
export function replaceStep(doc: Node, from: number, to = from, slice = Slice.empty): Step | null {
if (from == to && !slice.size) return null
let $from = doc.resolve(from), $to = doc.resolve(to)
// Optimization -- avoid work if it's obvious that it's not needed.
if (fitsTrivially($from, $to, slice)) return new ReplaceStep(from, to, slice)
return new Fitter($from, $to, slice).fit()
}
function fitsTrivially($from: ResolvedPos, $to: ResolvedPos, slice: Slice) {
return !slice.openStart && !slice.openEnd && $from.start() == $to.start() &&
$from.parent.canReplace($from.index(), $to.index(), slice.content)
}
interface Fittable {
sliceDepth: number
frontierDepth: number
parent: Node | null
inject?: Fragment | null
wrap?: readonly NodeType[]
}
// Algorithm for 'placing' the elements of a slice into a gap:
//
// We consider the content of each node that is open to the left to be
// independently placeable. I.e. in <p("foo"), p("bar")>, when the
// paragraph on the left is open, "foo" can be placed (somewhere on
// the left side of the replacement gap) independently from p("bar").
//
// This class tracks the state of the placement progress in the
// following properties:
//
// - `frontier` holds a stack of `{type, match}` objects that
// represent the open side of the replacement. It starts at
// `$from`, then moves forward as content is placed, and is finally
// reconciled with `$to`.
//
// - `unplaced` is a slice that represents the content that hasn't
// been placed yet.
//
// - `placed` is a fragment of placed content. Its open-start value
// is implicit in `$from`, and its open-end value in `frontier`.
class Fitter {
frontier: {type: NodeType, match: ContentMatch}[] = []
placed: Fragment = Fragment.empty
constructor(
readonly $from: ResolvedPos,
readonly $to: ResolvedPos,
public unplaced: Slice
) {
for (let i = 0; i <= $from.depth; i++) {
let node = $from.node(i)
this.frontier.push({
type: node.type,
match: node.contentMatchAt($from.indexAfter(i))
})
}
for (let i = $from.depth; i > 0; i--)
this.placed = Fragment.from($from.node(i).copy(this.placed))
}
get depth() { return this.frontier.length - 1 }
fit() {
// As long as there's unplaced content, try to place some of it.
// If that fails, either increase the open score of the unplaced
// slice, or drop nodes from it, and then try again.
while (this.unplaced.size) {
let fit = this.findFittable()
if (fit) this.placeNodes(fit)
else this.openMore() || this.dropNode()
}
// When there's inline content directly after the frontier _and_
// directly after `this.$to`, we must generate a `ReplaceAround`
// step that pulls that content into the node after the frontier.
// That means the fitting must be done to the end of the textblock
// node after `this.$to`, not `this.$to` itself.
let moveInline = this.mustMoveInline(), placedSize = this.placed.size - this.depth - this.$from.depth
let $from = this.$from, $to = this.close(moveInline < 0 ? this.$to : $from.doc.resolve(moveInline))
if (!$to) return null
// If closing to `$to` succeeded, create a step
let content = this.placed, openStart = $from.depth, openEnd = $to.depth
while (openStart && openEnd && content.childCount == 1) { // Normalize by dropping open parent nodes
content = content.firstChild!.content
openStart--; openEnd--
}
let slice = new Slice(content, openStart, openEnd)
if (moveInline > -1)
return new ReplaceAroundStep($from.pos, moveInline, this.$to.pos, this.$to.end(), slice, placedSize)
if (slice.size || $from.pos != this.$to.pos) // Don't generate no-op steps
return new ReplaceStep($from.pos, $to.pos, slice)
return null
}
// Find a position on the start spine of `this.unplaced` that has
// content that can be moved somewhere on the frontier. Returns two
// depths, one for the slice and one for the frontier.
findFittable(): Fittable | undefined {
let startDepth = this.unplaced.openStart
for (let cur = this.unplaced.content, d = 0, openEnd = this.unplaced.openEnd; d < startDepth; d++) {
let node = cur.firstChild!
if (cur.childCount > 1) openEnd = 0
if (node.type.spec.isolating && openEnd <= d) {
startDepth = d
break
}
cur = node.content
}
// Only try wrapping nodes (pass 2) after finding a place without
// wrapping failed.
for (let pass = 1; pass <= 2; pass++) {
for (let sliceDepth = pass == 1 ? startDepth : this.unplaced.openStart; sliceDepth >= 0; sliceDepth--) {
let fragment, parent = null
if (sliceDepth) {
parent = contentAt(this.unplaced.content, sliceDepth - 1).firstChild
fragment = parent!.content
} else {
fragment = this.unplaced.content
}
let first = fragment.firstChild
for (let frontierDepth = this.depth; frontierDepth >= 0; frontierDepth--) {
let {type, match} = this.frontier[frontierDepth], wrap, inject: Fragment | null = null
// In pass 1, if the next node matches, or there is no next
// node but the parents look compatible, we've found a
// place.
if (pass == 1 && (first ? match.matchType(first.type) || (inject = match.fillBefore(Fragment.from(first), false))
: parent && type.compatibleContent(parent.type)))
return {sliceDepth, frontierDepth, parent, inject}
// In pass 2, look for a set of wrapping nodes that make
// `first` fit here.
else if (pass == 2 && first && (wrap = match.findWrapping(first.type)))
return {sliceDepth, frontierDepth, parent, wrap}
// Don't continue looking further up if the parent node
// would fit here.
if (parent && match.matchType(parent.type)) break
}
}
}
}
openMore() {
let {content, openStart, openEnd} = this.unplaced
let inner = contentAt(content, openStart)
if (!inner.childCount || inner.firstChild!.isLeaf) return false
this.unplaced = new Slice(content, openStart + 1,
Math.max(openEnd, inner.size + openStart >= content.size - openEnd ? openStart + 1 : 0))
return true
}
dropNode() {
let {content, openStart, openEnd} = this.unplaced
let inner = contentAt(content, openStart)
if (inner.childCount <= 1 && openStart > 0) {
let openAtEnd = content.size - openStart <= openStart + inner.size
this.unplaced = new Slice(dropFromFragment(content, openStart - 1, 1), openStart - 1,
openAtEnd ? openStart - 1 : openEnd)
} else {
this.unplaced = new Slice(dropFromFragment(content, openStart, 1), openStart, openEnd)
}
}
// Move content from the unplaced slice at `sliceDepth` to the
// frontier node at `frontierDepth`. Close that frontier node when
// applicable.
placeNodes({sliceDepth, frontierDepth, parent, inject, wrap}: Fittable) {
while (this.depth > frontierDepth) this.closeFrontierNode()
if (wrap) for (let i = 0; i < wrap.length; i++) this.openFrontierNode(wrap[i])
let slice = this.unplaced, fragment = parent ? parent.content : slice.content
let openStart = slice.openStart - sliceDepth
let taken = 0, add = []
let {match, type} = this.frontier[frontierDepth]
if (inject) {
for (let i = 0; i < inject.childCount; i++) add.push(inject.child(i))
match = match.matchFragment(inject)!
}
// Computes the amount of (end) open nodes at the end of the
// fragment. When 0, the parent is open, but no more. When
// negative, nothing is open.
let openEndCount = (fragment.size + sliceDepth) - (slice.content.size - slice.openEnd)
// Scan over the fragment, fitting as many child nodes as
// possible.
while (taken < fragment.childCount) {
let next = fragment.child(taken), matches = match.matchType(next.type)
if (!matches) break
taken++
if (taken > 1 || openStart == 0 || next.content.size) { // Drop empty open nodes
match = matches
add.push(closeNodeStart(next.mark(type.allowedMarks(next.marks)), taken == 1 ? openStart : 0,
taken == fragment.childCount ? openEndCount : -1))
}
}
let toEnd = taken == fragment.childCount
if (!toEnd) openEndCount = -1
this.placed = addToFragment(this.placed, frontierDepth, Fragment.from(add))
this.frontier[frontierDepth].match = match
// If the parent types match, and the entire node was moved, and
// it's not open, close this frontier node right away.
if (toEnd && openEndCount < 0 && parent && parent.type == this.frontier[this.depth].type && this.frontier.length > 1)
this.closeFrontierNode()
// Add new frontier nodes for any open nodes at the end.
for (let i = 0, cur = fragment; i < openEndCount; i++) {
let node = cur.lastChild!
this.frontier.push({type: node.type, match: node.contentMatchAt(node.childCount)})
cur = node.content
}
// Update `this.unplaced`. Drop the entire node from which we
// placed it we got to its end, otherwise just drop the placed
// nodes.
this.unplaced = !toEnd ? new Slice(dropFromFragment(slice.content, sliceDepth, taken), slice.openStart, slice.openEnd)
: sliceDepth == 0 ? Slice.empty
: new Slice(dropFromFragment(slice.content, sliceDepth - 1, 1),
sliceDepth - 1, openEndCount < 0 ? slice.openEnd : sliceDepth - 1)
}
mustMoveInline() {
if (!this.$to.parent.isTextblock) return -1
let top = this.frontier[this.depth], level
if (!top.type.isTextblock || !contentAfterFits(this.$to, this.$to.depth, top.type, top.match, false) ||
(this.$to.depth == this.depth && (level = this.findCloseLevel(this.$to)) && level.depth == this.depth)) return -1
let {depth} = this.$to, after = this.$to.after(depth)
while (depth > 1 && after == this.$to.end(--depth)) ++after
return after
}
findCloseLevel($to: ResolvedPos) {
scan: for (let i = Math.min(this.depth, $to.depth); i >= 0; i--) {
let {match, type} = this.frontier[i]
let dropInner = i < $to.depth && $to.end(i + 1) == $to.pos + ($to.depth - (i + 1))
let fit = contentAfterFits($to, i, type, match, dropInner)
if (!fit) continue
for (let d = i - 1; d >= 0; d--) {
let {match, type} = this.frontier[d]
let matches = contentAfterFits($to, d, type, match, true)
if (!matches || matches.childCount) continue scan
}
return {depth: i, fit, move: dropInner ? $to.doc.resolve($to.after(i + 1)) : $to}
}
}
close($to: ResolvedPos) {
let close = this.findCloseLevel($to)
if (!close) return null
while (this.depth > close.depth) this.closeFrontierNode()
if (close.fit.childCount) this.placed = addToFragment(this.placed, close.depth, close.fit)
$to = close.move
for (let d = close.depth + 1; d <= $to.depth; d++) {
let node = $to.node(d), add = node.type.contentMatch.fillBefore(node.content, true, $to.index(d))!
this.openFrontierNode(node.type, node.attrs, add)
}
return $to
}
openFrontierNode(type: NodeType, attrs: Attrs | null = null, content?: Fragment) {
let top = this.frontier[this.depth]
top.match = top.match.matchType(type)!
this.placed = addToFragment(this.placed, this.depth, Fragment.from(type.create(attrs, content)))
this.frontier.push({type, match: type.contentMatch})
}
closeFrontierNode() {
let open = this.frontier.pop()!
let add = open.match.fillBefore(Fragment.empty, true)!
if (add.childCount) this.placed = addToFragment(this.placed, this.frontier.length, add)
}
}
function dropFromFragment(fragment: Fragment, depth: number, count: number): Fragment {
if (depth == 0) return fragment.cutByIndex(count, fragment.childCount)
return fragment.replaceChild(0, fragment.firstChild!.copy(dropFromFragment(fragment.firstChild!.content, depth - 1, count)))
}
function addToFragment(fragment: Fragment, depth: number, content: Fragment): Fragment {
if (depth == 0) return fragment.append(content)
return fragment.replaceChild(fragment.childCount - 1,
fragment.lastChild!.copy(addToFragment(fragment.lastChild!.content, depth - 1, content)))
}
function contentAt(fragment: Fragment, depth: number) {
for (let i = 0; i < depth; i++) fragment = fragment.firstChild!.content
return fragment
}
function closeNodeStart(node: Node, openStart: number, openEnd: number) {
if (openStart <= 0) return node
let frag = node.content
if (openStart > 1)
frag = frag.replaceChild(0, closeNodeStart(frag.firstChild!, openStart - 1, frag.childCount == 1 ? openEnd - 1 : 0))
if (openStart > 0) {
frag = node.type.contentMatch.fillBefore(frag)!.append(frag)
if (openEnd <= 0) frag = frag.append(node.type.contentMatch.matchFragment(frag)!.fillBefore(Fragment.empty, true)!)
}
return node.copy(frag)
}
function contentAfterFits($to: ResolvedPos, depth: number, type: NodeType, match: ContentMatch, open: boolean) {
let node = $to.node(depth), index = open ? $to.indexAfter(depth) : $to.index(depth)
if (index == node.childCount && !type.compatibleContent(node.type)) return null
let fit = match.fillBefore(node.content, true, index)
return fit && !invalidMarks(type, node.content, index) ? fit : null
}
function invalidMarks(type: NodeType, fragment: Fragment, start: number) {
for (let i = start; i < fragment.childCount; i++)
if (!type.allowsMarks(fragment.child(i).marks)) return true
return false
}
function definesContent(type: NodeType) {
return type.spec.defining || type.spec.definingForContent
}
export function replaceRange(tr: Transform, from: number, to: number, slice: Slice) {
if (!slice.size) return tr.deleteRange(from, to)
let $from = tr.doc.resolve(from), $to = tr.doc.resolve(to)
if (fitsTrivially($from, $to, slice))
return tr.step(new ReplaceStep(from, to, slice))
let targetDepths = coveredDepths($from, tr.doc.resolve(to))
// Can't replace the whole document, so remove 0 if it's present
if (targetDepths[targetDepths.length - 1] == 0) targetDepths.pop()
// Negative numbers represent not expansion over the whole node at
// that depth, but replacing from $from.before(-D) to $to.pos.
let preferredTarget = -($from.depth + 1)
targetDepths.unshift(preferredTarget)
// This loop picks a preferred target depth, if one of the covering
// depths is not outside of a defining node, and adds negative
// depths for any depth that has $from at its start and does not
// cross a defining node.
for (let d = $from.depth, pos = $from.pos - 1; d > 0; d--, pos--) {
let spec = $from.node(d).type.spec
if (spec.defining || spec.definingAsContext || spec.isolating) break
if (targetDepths.indexOf(d) > -1) preferredTarget = d
else if ($from.before(d) == pos) targetDepths.splice(1, 0, -d)
}
// Try to fit each possible depth of the slice into each possible
// target depth, starting with the preferred depths.
let preferredTargetIndex = targetDepths.indexOf(preferredTarget)
let leftNodes: Node[] = [], preferredDepth = slice.openStart
for (let content = slice.content, i = 0;; i++) {
let node = content.firstChild!
leftNodes.push(node)
if (i == slice.openStart) break
content = node.content
}
// Back up preferredDepth to cover defining textblocks directly
// above it, possibly skipping a non-defining textblock.
for (let d = preferredDepth - 1; d >= 0; d--) {
let leftNode = leftNodes[d], def = definesContent(leftNode.type)
if (def && !leftNode.sameMarkup($from.node(Math.abs(preferredTarget) - 1))) preferredDepth = d
else if (def || !leftNode.type.isTextblock) break
}
for (let j = slice.openStart; j >= 0; j--) {
let openDepth = (j + preferredDepth + 1) % (slice.openStart + 1)
let insert = leftNodes[openDepth]
if (!insert) continue
for (let i = 0; i < targetDepths.length; i++) {
// Loop over possible expansion levels, starting with the
// preferred one
let targetDepth = targetDepths[(i + preferredTargetIndex) % targetDepths.length], expand = true
if (targetDepth < 0) { expand = false; targetDepth = -targetDepth }
let parent = $from.node(targetDepth - 1), index = $from.index(targetDepth - 1)
if (parent.canReplaceWith(index, index, insert.type, insert.marks))
return tr.replace($from.before(targetDepth), expand ? $to.after(targetDepth) : to,
new Slice(closeFragment(slice.content, 0, slice.openStart, openDepth),
openDepth, slice.openEnd))
}
}
let startSteps = tr.steps.length
for (let i = targetDepths.length - 1; i >= 0; i--) {
tr.replace(from, to, slice)
if (tr.steps.length > startSteps) break
let depth = targetDepths[i]
if (depth < 0) continue
from = $from.before(depth); to = $to.after(depth)
}
}
function closeFragment(fragment: Fragment, depth: number, oldOpen: number, newOpen: number, parent?: Node) {
if (depth < oldOpen) {
let first = fragment.firstChild!
fragment = fragment.replaceChild(0, first.copy(closeFragment(first.content, depth + 1, oldOpen, newOpen, first)))
}
if (depth > newOpen) {
let match = parent!.contentMatchAt(0)!
let start = match.fillBefore(fragment)!.append(fragment)
fragment = start.append(match.matchFragment(start)!.fillBefore(Fragment.empty, true)!)
}
return fragment
}
export function replaceRangeWith(tr: Transform, from: number, to: number, node: Node) {
if (!node.isInline && from == to && tr.doc.resolve(from).parent.content.size) {
let point = insertPoint(tr.doc, from, node.type)
if (point != null) from = to = point
}
tr.replaceRange(from, to, new Slice(Fragment.from(node), 0, 0))
}
export function deleteRange(tr: Transform, from: number, to: number) {
let $from = tr.doc.resolve(from), $to = tr.doc.resolve(to)
let covered = coveredDepths($from, $to)
for (let i = 0; i < covered.length; i++) {
let depth = covered[i], last = i == covered.length - 1
if ((last && depth == 0) || $from.node(depth).type.contentMatch.validEnd)
return tr.delete($from.start(depth), $to.end(depth))
if (depth > 0 && (last || $from.node(depth - 1).canReplace($from.index(depth - 1), $to.indexAfter(depth - 1))))
return tr.delete($from.before(depth), $to.after(depth))
}
for (let d = 1; d <= $from.depth && d <= $to.depth; d++) {
if (from - $from.start(d) == $from.depth - d && to > $from.end(d) && $to.end(d) - to != $to.depth - d)
return tr.delete($from.before(d), to)
}
tr.delete(from, to)
}
// Returns an array of all depths for which $from - $to spans the
// whole content of the nodes at that depth.
function coveredDepths($from: ResolvedPos, $to: ResolvedPos) {
let result: number[] = [], minDepth = Math.min($from.depth, $to.depth)
for (let d = minDepth; d >= 0; d--) {
let start = $from.start(d)
if (start < $from.pos - ($from.depth - d) ||
$to.end(d) > $to.pos + ($to.depth - d) ||
$from.node(d).type.spec.isolating ||
$to.node(d).type.spec.isolating) break
if (start == $to.start(d) ||
(d == $from.depth && d == $to.depth && $from.parent.inlineContent && $to.parent.inlineContent &&
d && $to.start(d - 1) == start - 1))
result.push(d)
}
return result
}

View File

@@ -0,0 +1,178 @@
import {Slice, Node, Schema} from "prosemirror-model"
import {Step, StepResult} from "./step"
import {StepMap, Mappable} from "./map"
/// Replace a part of the document with a slice of new content.
export class ReplaceStep extends Step {
/// The given `slice` should fit the 'gap' between `from` and
/// `to`—the depths must line up, and the surrounding nodes must be
/// able to be joined with the open sides of the slice. When
/// `structure` is true, the step will fail if the content between
/// from and to is not just a sequence of closing and then opening
/// tokens (this is to guard against rebased replace steps
/// overwriting something they weren't supposed to).
constructor(
/// The start position of the replaced range.
readonly from: number,
/// The end position of the replaced range.
readonly to: number,
/// The slice to insert.
readonly slice: Slice,
/// @internal
readonly structure = false
) {
super()
}
apply(doc: Node) {
if (this.structure && contentBetween(doc, this.from, this.to))
return StepResult.fail("Structure replace would overwrite content")
return StepResult.fromReplace(doc, this.from, this.to, this.slice)
}
getMap() {
return new StepMap([this.from, this.to - this.from, this.slice.size])
}
invert(doc: Node) {
return new ReplaceStep(this.from, this.from + this.slice.size, doc.slice(this.from, this.to))
}
map(mapping: Mappable) {
let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1)
if (from.deletedAcross && to.deletedAcross) return null
return new ReplaceStep(from.pos, Math.max(from.pos, to.pos), this.slice)
}
merge(other: Step) {
if (!(other instanceof ReplaceStep) || other.structure || this.structure) return null
if (this.from + this.slice.size == other.from && !this.slice.openEnd && !other.slice.openStart) {
let slice = this.slice.size + other.slice.size == 0 ? Slice.empty
: new Slice(this.slice.content.append(other.slice.content), this.slice.openStart, other.slice.openEnd)
return new ReplaceStep(this.from, this.to + (other.to - other.from), slice, this.structure)
} else if (other.to == this.from && !this.slice.openStart && !other.slice.openEnd) {
let slice = this.slice.size + other.slice.size == 0 ? Slice.empty
: new Slice(other.slice.content.append(this.slice.content), other.slice.openStart, this.slice.openEnd)
return new ReplaceStep(other.from, this.to, slice, this.structure)
} else {
return null
}
}
toJSON(): any {
let json: any = {stepType: "replace", from: this.from, to: this.to}
if (this.slice.size) json.slice = this.slice.toJSON()
if (this.structure) json.structure = true
return json
}
/// @internal
static fromJSON(schema: Schema, json: any) {
if (typeof json.from != "number" || typeof json.to != "number")
throw new RangeError("Invalid input for ReplaceStep.fromJSON")
return new ReplaceStep(json.from, json.to, Slice.fromJSON(schema, json.slice), !!json.structure)
}
}
Step.jsonID("replace", ReplaceStep)
/// Replace a part of the document with a slice of content, but
/// preserve a range of the replaced content by moving it into the
/// slice.
export class ReplaceAroundStep extends Step {
/// Create a replace-around step with the given range and gap.
/// `insert` should be the point in the slice into which the content
/// of the gap should be moved. `structure` has the same meaning as
/// it has in the [`ReplaceStep`](#transform.ReplaceStep) class.
constructor(
/// The start position of the replaced range.
readonly from: number,
/// The end position of the replaced range.
readonly to: number,
/// The start of preserved range.
readonly gapFrom: number,
/// The end of preserved range.
readonly gapTo: number,
/// The slice to insert.
readonly slice: Slice,
/// The position in the slice where the preserved range should be
/// inserted.
readonly insert: number,
/// @internal
readonly structure = false
) {
super()
}
apply(doc: Node) {
if (this.structure && (contentBetween(doc, this.from, this.gapFrom) ||
contentBetween(doc, this.gapTo, this.to)))
return StepResult.fail("Structure gap-replace would overwrite content")
let gap = doc.slice(this.gapFrom, this.gapTo)
if (gap.openStart || gap.openEnd)
return StepResult.fail("Gap is not a flat range")
let inserted = this.slice.insertAt(this.insert, gap.content)
if (!inserted) return StepResult.fail("Content does not fit in gap")
return StepResult.fromReplace(doc, this.from, this.to, inserted)
}
getMap() {
return new StepMap([this.from, this.gapFrom - this.from, this.insert,
this.gapTo, this.to - this.gapTo, this.slice.size - this.insert])
}
invert(doc: Node) {
let gap = this.gapTo - this.gapFrom
return new ReplaceAroundStep(this.from, this.from + this.slice.size + gap,
this.from + this.insert, this.from + this.insert + gap,
doc.slice(this.from, this.to).removeBetween(this.gapFrom - this.from, this.gapTo - this.from),
this.gapFrom - this.from, this.structure)
}
map(mapping: Mappable) {
let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1)
let gapFrom = this.from == this.gapFrom ? from.pos : mapping.map(this.gapFrom, -1)
let gapTo = this.to == this.gapTo ? to.pos : mapping.map(this.gapTo, 1)
if ((from.deletedAcross && to.deletedAcross) || gapFrom < from.pos || gapTo > to.pos) return null
return new ReplaceAroundStep(from.pos, to.pos, gapFrom, gapTo, this.slice, this.insert, this.structure)
}
toJSON(): any {
let json: any = {stepType: "replaceAround", from: this.from, to: this.to,
gapFrom: this.gapFrom, gapTo: this.gapTo, insert: this.insert}
if (this.slice.size) json.slice = this.slice.toJSON()
if (this.structure) json.structure = true
return json
}
/// @internal
static fromJSON(schema: Schema, json: any) {
if (typeof json.from != "number" || typeof json.to != "number" ||
typeof json.gapFrom != "number" || typeof json.gapTo != "number" || typeof json.insert != "number")
throw new RangeError("Invalid input for ReplaceAroundStep.fromJSON")
return new ReplaceAroundStep(json.from, json.to, json.gapFrom, json.gapTo,
Slice.fromJSON(schema, json.slice), json.insert, !!json.structure)
}
}
Step.jsonID("replaceAround", ReplaceAroundStep)
function contentBetween(doc: Node, from: number, to: number) {
let $from = doc.resolve(from), dist = to - from, depth = $from.depth
while (dist > 0 && depth > 0 && $from.indexAfter(depth) == $from.node(depth).childCount) {
depth--
dist--
}
if (dist > 0) {
let next = $from.node(depth).maybeChild($from.indexAfter(depth))
while (dist > 0) {
if (!next || next.isLeaf) return true
next = next.firstChild
dist--
}
}
return false
}

View File

@@ -0,0 +1,97 @@
import {ReplaceError, Schema, Slice, Node} from "prosemirror-model"
import {StepMap, Mappable} from "./map"
const stepsByID: {[id: string]: {fromJSON(schema: Schema, json: any): Step}} = Object.create(null)
/// A step object represents an atomic change. It generally applies
/// only to the document it was created for, since the positions
/// stored in it will only make sense for that document.
///
/// New steps are defined by creating classes that extend `Step`,
/// overriding the `apply`, `invert`, `map`, `getMap` and `fromJSON`
/// methods, and registering your class with a unique
/// JSON-serialization identifier using
/// [`Step.jsonID`](#transform.Step^jsonID).
export abstract class Step {
/// Applies this step to the given document, returning a result
/// object that either indicates failure, if the step can not be
/// applied to this document, or indicates success by containing a
/// transformed document.
abstract apply(doc: Node): StepResult
/// Get the step map that represents the changes made by this step,
/// and which can be used to transform between positions in the old
/// and the new document.
getMap(): StepMap { return StepMap.empty }
/// Create an inverted version of this step. Needs the document as it
/// was before the step as argument.
abstract invert(doc: Node): Step
/// Map this step through a mappable thing, returning either a
/// version of that step with its positions adjusted, or `null` if
/// the step was entirely deleted by the mapping.
abstract map(mapping: Mappable): Step | null
/// Try to merge this step with another one, to be applied directly
/// after it. Returns the merged step when possible, null if the
/// steps can't be merged.
merge(other: Step): Step | null { return null }
/// Create a JSON-serializeable representation of this step. When
/// defining this for a custom subclass, make sure the result object
/// includes the step type's [JSON id](#transform.Step^jsonID) under
/// the `stepType` property.
abstract toJSON(): any
/// Deserialize a step from its JSON representation. Will call
/// through to the step class' own implementation of this method.
static fromJSON(schema: Schema, json: any): Step {
if (!json || !json.stepType) throw new RangeError("Invalid input for Step.fromJSON")
let type = stepsByID[json.stepType]
if (!type) throw new RangeError(`No step type ${json.stepType} defined`)
return type.fromJSON(schema, json)
}
/// To be able to serialize steps to JSON, each step needs a string
/// ID to attach to its JSON representation. Use this method to
/// register an ID for your step classes. Try to pick something
/// that's unlikely to clash with steps from other modules.
static jsonID(id: string, stepClass: {fromJSON(schema: Schema, json: any): Step}) {
if (id in stepsByID) throw new RangeError("Duplicate use of step JSON ID " + id)
stepsByID[id] = stepClass
;(stepClass as any).prototype.jsonID = id
return stepClass
}
}
/// The result of [applying](#transform.Step.apply) a step. Contains either a
/// new document or a failure value.
export class StepResult {
/// @internal
constructor(
/// The transformed document, if successful.
readonly doc: Node | null,
/// The failure message, if unsuccessful.
readonly failed: string | null
) {}
/// Create a successful step result.
static ok(doc: Node) { return new StepResult(doc, null) }
/// Create a failed step result.
static fail(message: string) { return new StepResult(null, message) }
/// Call [`Node.replace`](#model.Node.replace) with the given
/// arguments. Create a successful result if it succeeds, and a
/// failed one if it throws a `ReplaceError`.
static fromReplace(doc: Node, from: number, to: number, slice: Slice) {
try {
return StepResult.ok(doc.replace(from, to, slice))
} catch (e) {
if (e instanceof ReplaceError) return StepResult.fail(e.message)
throw e
}
}
}

View File

@@ -0,0 +1,308 @@
import {Slice, Fragment, NodeRange, NodeType, Node, Mark, Attrs, ContentMatch} from "prosemirror-model"
import {Transform} from "./transform"
import {ReplaceStep, ReplaceAroundStep} from "./replace_step"
import {clearIncompatible} from "./mark"
function canCut(node: Node, start: number, end: number) {
return (start == 0 || node.canReplace(start, node.childCount)) &&
(end == node.childCount || node.canReplace(0, end))
}
/// Try to find a target depth to which the content in the given range
/// can be lifted. Will not go across
/// [isolating](#model.NodeSpec.isolating) parent nodes.
export function liftTarget(range: NodeRange): number | null {
let parent = range.parent
let content = parent.content.cutByIndex(range.startIndex, range.endIndex)
for (let depth = range.depth;; --depth) {
let node = range.$from.node(depth)
let index = range.$from.index(depth), endIndex = range.$to.indexAfter(depth)
if (depth < range.depth && node.canReplace(index, endIndex, content))
return depth
if (depth == 0 || node.type.spec.isolating || !canCut(node, index, endIndex)) break
}
return null
}
export function lift(tr: Transform, range: NodeRange, target: number) {
let {$from, $to, depth} = range
let gapStart = $from.before(depth + 1), gapEnd = $to.after(depth + 1)
let start = gapStart, end = gapEnd
let before = Fragment.empty, openStart = 0
for (let d = depth, splitting = false; d > target; d--)
if (splitting || $from.index(d) > 0) {
splitting = true
before = Fragment.from($from.node(d).copy(before))
openStart++
} else {
start--
}
let after = Fragment.empty, openEnd = 0
for (let d = depth, splitting = false; d > target; d--)
if (splitting || $to.after(d + 1) < $to.end(d)) {
splitting = true
after = Fragment.from($to.node(d).copy(after))
openEnd++
} else {
end++
}
tr.step(new ReplaceAroundStep(start, end, gapStart, gapEnd,
new Slice(before.append(after), openStart, openEnd),
before.size - openStart, true))
}
/// Try to find a valid way to wrap the content in the given range in a
/// node of the given type. May introduce extra nodes around and inside
/// the wrapper node, if necessary. Returns null if no valid wrapping
/// could be found. When `innerRange` is given, that range's content is
/// used as the content to fit into the wrapping, instead of the
/// content of `range`.
export function findWrapping(
range: NodeRange,
nodeType: NodeType,
attrs: Attrs | null = null,
innerRange = range
): {type: NodeType, attrs: Attrs | null}[] | null {
let around = findWrappingOutside(range, nodeType)
let inner = around && findWrappingInside(innerRange, nodeType)
if (!inner) return null
return (around!.map(withAttrs) as {type: NodeType, attrs: Attrs | null}[])
.concat({type: nodeType, attrs}).concat(inner.map(withAttrs))
}
function withAttrs(type: NodeType) { return {type, attrs: null} }
function findWrappingOutside(range: NodeRange, type: NodeType) {
let {parent, startIndex, endIndex} = range
let around = parent.contentMatchAt(startIndex).findWrapping(type)
if (!around) return null
let outer = around.length ? around[0] : type
return parent.canReplaceWith(startIndex, endIndex, outer) ? around : null
}
function findWrappingInside(range: NodeRange, type: NodeType) {
let {parent, startIndex, endIndex} = range
let inner = parent.child(startIndex)
let inside = type.contentMatch.findWrapping(inner.type)
if (!inside) return null
let lastType = inside.length ? inside[inside.length - 1] : type
let innerMatch: ContentMatch | null = lastType.contentMatch
for (let i = startIndex; innerMatch && i < endIndex; i++)
innerMatch = innerMatch.matchType(parent.child(i).type)
if (!innerMatch || !innerMatch.validEnd) return null
return inside
}
export function wrap(tr: Transform, range: NodeRange, wrappers: readonly {type: NodeType, attrs?: Attrs | null}[]) {
let content = Fragment.empty
for (let i = wrappers.length - 1; i >= 0; i--) {
if (content.size) {
let match = wrappers[i].type.contentMatch.matchFragment(content)
if (!match || !match.validEnd)
throw new RangeError("Wrapper type given to Transform.wrap does not form valid content of its parent wrapper")
}
content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content))
}
let start = range.start, end = range.end
tr.step(new ReplaceAroundStep(start, end, start, end, new Slice(content, 0, 0), wrappers.length, true))
}
export function setBlockType(tr: Transform, from: number, to: number, type: NodeType, attrs: Attrs | null) {
if (!type.isTextblock) throw new RangeError("Type given to setBlockType should be a textblock")
let mapFrom = tr.steps.length
tr.doc.nodesBetween(from, to, (node, pos) => {
if (node.isTextblock && !node.hasMarkup(type, attrs) && canChangeType(tr.doc, tr.mapping.slice(mapFrom).map(pos), type)) {
let convertNewlines = null
if (type.schema.linebreakReplacement) {
let pre = type.whitespace == "pre", supportLinebreak = !!type.contentMatch.matchType(type.schema.linebreakReplacement)
if (pre && !supportLinebreak) convertNewlines = false
else if (!pre && supportLinebreak) convertNewlines = true
}
// Ensure all markup that isn't allowed in the new node type is cleared
if (convertNewlines === false) replaceLinebreaks(tr, node, pos, mapFrom)
clearIncompatible(tr, tr.mapping.slice(mapFrom).map(pos, 1), type, undefined, convertNewlines === null)
let mapping = tr.mapping.slice(mapFrom)
let startM = mapping.map(pos, 1), endM = mapping.map(pos + node.nodeSize, 1)
tr.step(new ReplaceAroundStep(startM, endM, startM + 1, endM - 1,
new Slice(Fragment.from(type.create(attrs, null, node.marks)), 0, 0), 1, true))
if (convertNewlines === true) replaceNewlines(tr, node, pos, mapFrom)
return false
}
})
}
function replaceNewlines(tr: Transform, node: Node, pos: number, mapFrom: number) {
node.forEach((child, offset) => {
if (child.isText) {
let m, newline = /\r?\n|\r/g
while (m = newline.exec(child.text!)) {
let start = tr.mapping.slice(mapFrom).map(pos + 1 + offset + m.index)
tr.replaceWith(start, start + 1, node.type.schema.linebreakReplacement!.create())
}
}
})
}
function replaceLinebreaks(tr: Transform, node: Node, pos: number, mapFrom: number) {
node.forEach((child, offset) => {
if (child.type == child.type.schema.linebreakReplacement) {
let start = tr.mapping.slice(mapFrom).map(pos + 1 + offset)
tr.replaceWith(start, start + 1, node.type.schema.text("\n"))
}
})
}
function canChangeType(doc: Node, pos: number, type: NodeType) {
let $pos = doc.resolve(pos), index = $pos.index()
return $pos.parent.canReplaceWith(index, index + 1, type)
}
/// Change the type, attributes, and/or marks of the node at `pos`.
/// When `type` isn't given, the existing node type is preserved,
export function setNodeMarkup(tr: Transform, pos: number, type: NodeType | undefined | null,
attrs: Attrs | null, marks: readonly Mark[] | undefined) {
let node = tr.doc.nodeAt(pos)
if (!node) throw new RangeError("No node at given position")
if (!type) type = node.type
let newNode = type.create(attrs, null, marks || node.marks)
if (node.isLeaf)
return tr.replaceWith(pos, pos + node.nodeSize, newNode)
if (!type.validContent(node.content))
throw new RangeError("Invalid content for node type " + type.name)
tr.step(new ReplaceAroundStep(pos, pos + node.nodeSize, pos + 1, pos + node.nodeSize - 1,
new Slice(Fragment.from(newNode), 0, 0), 1, true))
}
/// Check whether splitting at the given position is allowed.
export function canSplit(doc: Node, pos: number, depth = 1,
typesAfter?: (null | {type: NodeType, attrs?: Attrs | null})[]): boolean {
let $pos = doc.resolve(pos), base = $pos.depth - depth
let innerType = (typesAfter && typesAfter[typesAfter.length - 1]) || $pos.parent
if (base < 0 || $pos.parent.type.spec.isolating ||
!$pos.parent.canReplace($pos.index(), $pos.parent.childCount) ||
!innerType.type.validContent($pos.parent.content.cutByIndex($pos.index(), $pos.parent.childCount)))
return false
for (let d = $pos.depth - 1, i = depth - 2; d > base; d--, i--) {
let node = $pos.node(d), index = $pos.index(d)
if (node.type.spec.isolating) return false
let rest = node.content.cutByIndex(index, node.childCount)
let overrideChild = typesAfter && typesAfter[i + 1]
if (overrideChild)
rest = rest.replaceChild(0, overrideChild.type.create(overrideChild.attrs))
let after = (typesAfter && typesAfter[i]) || node
if (!node.canReplace(index + 1, node.childCount) || !after.type.validContent(rest))
return false
}
let index = $pos.indexAfter(base)
let baseType = typesAfter && typesAfter[0]
return $pos.node(base).canReplaceWith(index, index, baseType ? baseType.type : $pos.node(base + 1).type)
}
export function split(tr: Transform, pos: number, depth = 1, typesAfter?: (null | {type: NodeType, attrs?: Attrs | null})[]) {
let $pos = tr.doc.resolve(pos), before = Fragment.empty, after = Fragment.empty
for (let d = $pos.depth, e = $pos.depth - depth, i = depth - 1; d > e; d--, i--) {
before = Fragment.from($pos.node(d).copy(before))
let typeAfter = typesAfter && typesAfter[i]
after = Fragment.from(typeAfter ? typeAfter.type.create(typeAfter.attrs, after) : $pos.node(d).copy(after))
}
tr.step(new ReplaceStep(pos, pos, new Slice(before.append(after), depth, depth), true))
}
/// Test whether the blocks before and after a given position can be
/// joined.
export function canJoin(doc: Node, pos: number): boolean {
let $pos = doc.resolve(pos), index = $pos.index()
return joinable($pos.nodeBefore, $pos.nodeAfter) &&
$pos.parent.canReplace(index, index + 1)
}
function joinable(a: Node | null, b: Node | null) {
return !!(a && b && !a.isLeaf && a.canAppend(b))
}
/// Find an ancestor of the given position that can be joined to the
/// block before (or after if `dir` is positive). Returns the joinable
/// point, if any.
export function joinPoint(doc: Node, pos: number, dir = -1) {
let $pos = doc.resolve(pos)
for (let d = $pos.depth;; d--) {
let before, after, index = $pos.index(d)
if (d == $pos.depth) {
before = $pos.nodeBefore
after = $pos.nodeAfter
} else if (dir > 0) {
before = $pos.node(d + 1)
index++
after = $pos.node(d).maybeChild(index)
} else {
before = $pos.node(d).maybeChild(index - 1)
after = $pos.node(d + 1)
}
if (before && !before.isTextblock && joinable(before, after) &&
$pos.node(d).canReplace(index, index + 1)) return pos
if (d == 0) break
pos = dir < 0 ? $pos.before(d) : $pos.after(d)
}
}
export function join(tr: Transform, pos: number, depth: number) {
let step = new ReplaceStep(pos - depth, pos + depth, Slice.empty, true)
tr.step(step)
}
/// Try to find a point where a node of the given type can be inserted
/// near `pos`, by searching up the node hierarchy when `pos` itself
/// isn't a valid place but is at the start or end of a node. Return
/// null if no position was found.
export function insertPoint(doc: Node, pos: number, nodeType: NodeType): number | null {
let $pos = doc.resolve(pos)
if ($pos.parent.canReplaceWith($pos.index(), $pos.index(), nodeType)) return pos
if ($pos.parentOffset == 0)
for (let d = $pos.depth - 1; d >= 0; d--) {
let index = $pos.index(d)
if ($pos.node(d).canReplaceWith(index, index, nodeType)) return $pos.before(d + 1)
if (index > 0) return null
}
if ($pos.parentOffset == $pos.parent.content.size)
for (let d = $pos.depth - 1; d >= 0; d--) {
let index = $pos.indexAfter(d)
if ($pos.node(d).canReplaceWith(index, index, nodeType)) return $pos.after(d + 1)
if (index < $pos.node(d).childCount) return null
}
return null
}
/// Finds a position at or around the given position where the given
/// slice can be inserted. Will look at parent nodes' nearest boundary
/// and try there, even if the original position wasn't directly at the
/// start or end of that node. Returns null when no position was found.
export function dropPoint(doc: Node, pos: number, slice: Slice): number | null {
let $pos = doc.resolve(pos)
if (!slice.content.size) return pos
let content = slice.content
for (let i = 0; i < slice.openStart; i++) content = content.firstChild!.content
for (let pass = 1; pass <= (slice.openStart == 0 && slice.size ? 2 : 1); pass++) {
for (let d = $pos.depth; d >= 0; d--) {
let bias = d == $pos.depth ? 0 : $pos.pos <= ($pos.start(d + 1) + $pos.end(d + 1)) / 2 ? -1 : 1
let insertPos = $pos.index(d) + (bias > 0 ? 1 : 0)
let parent = $pos.node(d), fits: boolean | null = false
if (pass == 1) {
fits = parent.canReplace(insertPos, insertPos, content)
} else {
let wrapping = parent.contentMatchAt(insertPos).findWrapping(content.firstChild!.type)
fits = wrapping && parent.canReplaceWith(insertPos, insertPos, wrapping[0])
}
if (fits)
return bias == 0 ? $pos.pos : bias < 0 ? $pos.before(d + 1) : $pos.after(d + 1)
}
}
return null
}

View File

@@ -0,0 +1,247 @@
import {Node, NodeType, Mark, MarkType, ContentMatch, Slice, Fragment, NodeRange, Attrs} from "prosemirror-model"
import {Mapping} from "./map"
import {Step} from "./step"
import {addMark, removeMark, clearIncompatible} from "./mark"
import {replaceStep, replaceRange, replaceRangeWith, deleteRange} from "./replace"
import {lift, wrap, setBlockType, setNodeMarkup, split, join} from "./structure"
import {AttrStep, DocAttrStep} from "./attr_step"
import {AddNodeMarkStep, RemoveNodeMarkStep} from "./mark_step"
/// @internal
export let TransformError = class extends Error {}
TransformError = function TransformError(this: any, message: string) {
let err = Error.call(this, message)
;(err as any).__proto__ = TransformError.prototype
return err
} as any
TransformError.prototype = Object.create(Error.prototype)
TransformError.prototype.constructor = TransformError
TransformError.prototype.name = "TransformError"
/// Abstraction to build up and track an array of
/// [steps](#transform.Step) representing a document transformation.
///
/// Most transforming methods return the `Transform` object itself, so
/// that they can be chained.
export class Transform {
/// The steps in this transform.
readonly steps: Step[] = []
/// The documents before each of the steps.
readonly docs: Node[] = []
/// A mapping with the maps for each of the steps in this transform.
readonly mapping: Mapping = new Mapping
/// Create a transform that starts with the given document.
constructor(
/// The current document (the result of applying the steps in the
/// transform).
public doc: Node
) {}
/// The starting document.
get before() { return this.docs.length ? this.docs[0] : this.doc }
/// Apply a new step in this transform, saving the result. Throws an
/// error when the step fails.
step(step: Step) {
let result = this.maybeStep(step)
if (result.failed) throw new TransformError(result.failed)
return this
}
/// Try to apply a step in this transformation, ignoring it if it
/// fails. Returns the step result.
maybeStep(step: Step) {
let result = step.apply(this.doc)
if (!result.failed) this.addStep(step, result.doc!)
return result
}
/// True when the document has been changed (when there are any
/// steps).
get docChanged() {
return this.steps.length > 0
}
/// @internal
addStep(step: Step, doc: Node) {
this.docs.push(this.doc)
this.steps.push(step)
this.mapping.appendMap(step.getMap())
this.doc = doc
}
/// Replace the part of the document between `from` and `to` with the
/// given `slice`.
replace(from: number, to = from, slice = Slice.empty): this {
let step = replaceStep(this.doc, from, to, slice)
if (step) this.step(step)
return this
}
/// Replace the given range with the given content, which may be a
/// fragment, node, or array of nodes.
replaceWith(from: number, to: number, content: Fragment | Node | readonly Node[]): this {
return this.replace(from, to, new Slice(Fragment.from(content), 0, 0))
}
/// Delete the content between the given positions.
delete(from: number, to: number): this {
return this.replace(from, to, Slice.empty)
}
/// Insert the given content at the given position.
insert(pos: number, content: Fragment | Node | readonly Node[]): this {
return this.replaceWith(pos, pos, content)
}
/// Replace a range of the document with a given slice, using
/// `from`, `to`, and the slice's
/// [`openStart`](#model.Slice.openStart) property as hints, rather
/// than fixed start and end points. This method may grow the
/// replaced area or close open nodes in the slice in order to get a
/// fit that is more in line with WYSIWYG expectations, by dropping
/// fully covered parent nodes of the replaced region when they are
/// marked [non-defining as
/// context](#model.NodeSpec.definingAsContext), or including an
/// open parent node from the slice that _is_ marked as [defining
/// its content](#model.NodeSpec.definingForContent).
///
/// This is the method, for example, to handle paste. The similar
/// [`replace`](#transform.Transform.replace) method is a more
/// primitive tool which will _not_ move the start and end of its given
/// range, and is useful in situations where you need more precise
/// control over what happens.
replaceRange(from: number, to: number, slice: Slice): this {
replaceRange(this, from, to, slice)
return this
}
/// Replace the given range with a node, but use `from` and `to` as
/// hints, rather than precise positions. When from and to are the same
/// and are at the start or end of a parent node in which the given
/// node doesn't fit, this method may _move_ them out towards a parent
/// that does allow the given node to be placed. When the given range
/// completely covers a parent node, this method may completely replace
/// that parent node.
replaceRangeWith(from: number, to: number, node: Node): this {
replaceRangeWith(this, from, to, node)
return this
}
/// Delete the given range, expanding it to cover fully covered
/// parent nodes until a valid replace is found.
deleteRange(from: number, to: number): this {
deleteRange(this, from, to)
return this
}
/// Split the content in the given range off from its parent, if there
/// is sibling content before or after it, and move it up the tree to
/// the depth specified by `target`. You'll probably want to use
/// [`liftTarget`](#transform.liftTarget) to compute `target`, to make
/// sure the lift is valid.
lift(range: NodeRange, target: number): this {
lift(this, range, target)
return this
}
/// Join the blocks around the given position. If depth is 2, their
/// last and first siblings are also joined, and so on.
join(pos: number, depth: number = 1): this {
join(this, pos, depth)
return this
}
/// Wrap the given [range](#model.NodeRange) in the given set of wrappers.
/// The wrappers are assumed to be valid in this position, and should
/// probably be computed with [`findWrapping`](#transform.findWrapping).
wrap(range: NodeRange, wrappers: readonly {type: NodeType, attrs?: Attrs | null}[]): this {
wrap(this, range, wrappers)
return this
}
/// Set the type of all textblocks (partly) between `from` and `to` to
/// the given node type with the given attributes.
setBlockType(from: number, to = from, type: NodeType, attrs: Attrs | null = null): this {
setBlockType(this, from, to, type, attrs)
return this
}
/// Change the type, attributes, and/or marks of the node at `pos`.
/// When `type` isn't given, the existing node type is preserved,
setNodeMarkup(pos: number, type?: NodeType | null, attrs: Attrs | null = null, marks?: readonly Mark[]): this {
setNodeMarkup(this, pos, type, attrs, marks)
return this
}
/// Set a single attribute on a given node to a new value.
/// The `pos` addresses the document content. Use `setDocAttribute`
/// to set attributes on the document itself.
setNodeAttribute(pos: number, attr: string, value: any): this {
this.step(new AttrStep(pos, attr, value))
return this
}
/// Set a single attribute on the document to a new value.
setDocAttribute(attr: string, value: any): this {
this.step(new DocAttrStep(attr, value))
return this
}
/// Add a mark to the node at position `pos`.
addNodeMark(pos: number, mark: Mark): this {
this.step(new AddNodeMarkStep(pos, mark))
return this
}
/// Remove a mark (or a mark of the given type) from the node at
/// position `pos`.
removeNodeMark(pos: number, mark: Mark | MarkType): this {
if (!(mark instanceof Mark)) {
let node = this.doc.nodeAt(pos)
if (!node) throw new RangeError("No node at position " + pos)
mark = mark.isInSet(node.marks)!
if (!mark) return this
}
this.step(new RemoveNodeMarkStep(pos, mark))
return this
}
/// Split the node at the given position, and optionally, if `depth` is
/// greater than one, any number of nodes above that. By default, the
/// parts split off will inherit the node type of the original node.
/// This can be changed by passing an array of types and attributes to
/// use after the split.
split(pos: number, depth = 1, typesAfter?: (null | {type: NodeType, attrs?: Attrs | null})[]) {
split(this, pos, depth, typesAfter)
return this
}
/// Add the given mark to the inline content between `from` and `to`.
addMark(from: number, to: number, mark: Mark): this {
addMark(this, from, to, mark)
return this
}
/// Remove marks from inline nodes between `from` and `to`. When
/// `mark` is a single mark, remove precisely that mark. When it is
/// a mark type, remove all marks of that type. When it is null,
/// remove all marks of any type.
removeMark(from: number, to: number, mark?: Mark | MarkType | null) {
removeMark(this, from, to, mark)
return this
}
/// Removes all marks and nodes from the content of the node at
/// `pos` that don't match the given new parent node type. Accepts
/// an optional starting [content match](#model.ContentMatch) as
/// third argument.
clearIncompatible(pos: number, parentType: NodeType, match?: ContentMatch) {
clearIncompatible(this, pos, parentType, match)
return this
}
}