diff --git a/README.md b/README.md index 50b3bdb..6b212f4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This package provides Python implementations of the following [ProseMirror](https://prosemirror.net/) packages: - [`prosemirror-model`](https://github.com/ProseMirror/prosemirror-model) version 1.18.1 -- [`prosemirror-transform`](https://github.com/ProseMirror/prosemirror-transform) version 1.6.0 +- [`prosemirror-transform`](https://github.com/ProseMirror/prosemirror-transform) version 1.7.1 - [`prosemirror-test-builder`](https://github.com/ProseMirror/prosemirror-test-builder) - [`prosemirror-schema-basic`](https://github.com/ProseMirror/prosemirror-schema-basic) version 1.1.2 - [`prosemirror-schema-list`](https://github.com/ProseMirror/prosemirror-schema-list) @@ -87,4 +87,4 @@ assert tr.doc.to_json() == { }] }] } -``` \ No newline at end of file +``` diff --git a/poetry.lock b/poetry.lock index 62adeaa..9978472 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] name = "attrs" @@ -182,14 +182,14 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "codecov" -version = "2.1.12" +version = "2.1.13" description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ - {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, - {file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"}, + {file = "codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5"}, + {file = "codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c"}, ] [package.dependencies] @@ -725,4 +725,4 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<4" -content-hash = "2d986f3721f23927f09587eee9ed4c3efdba93943a2c545e380418077f259b8a" +content-hash = "f5c6e0791a18839add2821b9e38fa4c3c25980701713919c7038348312791f43" diff --git a/prosemirror/transform/mark_step.py b/prosemirror/transform/mark_step.py index eb538e2..c77afb3 100644 --- a/prosemirror/transform/mark_step.py +++ b/prosemirror/transform/mark_step.py @@ -153,3 +153,112 @@ def from_json(schema, json_data): Step.json_id("removeMark", RemoveMarkStep) + + +class AddNodeMarkStep(Step): + def __init__(self, pos, mark): + super().__init__() + self.pos = pos + self.mark = mark + + def apply(self, doc): + node = doc.node_at(self.pos) + if not node: + return StepResult.fail("No node at mark step's position") + updated = node.type.create(node.attrs, None, self.mark.add_to_set(node.marks)) + return StepResult.from_replace( + doc, + self.pos, + self.pos + 1, + Slice(Fragment.from_(updated), 0, 0 if node.is_leaf else 1), + ) + + def invert(self, doc): + node = doc.node_at(self.pos) + if node: + new_set = self.mark.add_to_set(node.marks) + if len(new_set) == len(node.marks): + for i in range(len(node.marks)): + if not node.marks[i].is_in_set(new_set): + return AddNodeMarkStep(self.pos, node.marks[i]) + return AddNodeMarkStep(self.pos, self.mark) + return RemoveNodeMarkStep(self.pos, self.mark) + + def map(self, mapping): + pos = mapping.map_result(self.pos, 1) + return None if pos.deleted_after else AddNodeMarkStep(pos.pos, self.mark) + + def to_json(self): + return { + "stepType": "addNodeMark", + "pos": self.pos, + "mark": self.mark.to_json(), + } + + @staticmethod + def from_json(schema, json_data): + if isinstance(json_data, str): + import json + + json_data = json.loads(json_data) + if not isinstance(json_data["pos"], int): + raise ValueError("Invalid input for AddNodeMarkStep.from_json") + return AddNodeMarkStep( + json_data["pos"], schema.mark_from_json(json_data["mark"]) + ) + + +Step.json_id("addNodeMark", AddNodeMarkStep) + + +class RemoveNodeMarkStep(Step): + def __init__(self, pos, mark): + super().__init__() + self.pos = pos + self.mark = mark + + def apply(self, doc): + node = doc.node_at(self.pos) + if not node: + return StepResult.fail("No node at mark step's position") + updated = node.type.create( + node.attrs, None, self.mark.remove_from_set(node.marks) + ) + return StepResult.from_replace( + doc, + self.pos, + self.pos + 1, + Slice(Fragment.from_(updated), 0, 0 if node.is_leaf else 1), + ) + + def invert(self, doc): + node = doc.node_at(self.pos) + if not node or not self.mark.is_in_set(node.marks): + return self + return AddNodeMarkStep(self.pos, self.mark) + + def map(self, mapping): + pos = mapping.map_result(self.pos, 1) + return None if pos.deleted_after else RemoveNodeMarkStep(pos.pos, self.mark) + + def to_json(self): + return { + "stepType": "removeNodeMark", + "pos": self.pos, + "mark": self.mark.to_json(), + } + + @staticmethod + def from_json(schema, json_data): + if isinstance(json_data, str): + import json + + json_data = json.loads(json_data) + if not isinstance(json_data["pos"], int): + raise ValueError("Invalid input for RemoveNodeMarkStep.from_json") + return RemoveNodeMarkStep( + json_data["pos"], schema.mark_from_json(json_data["mark"]) + ) + + +Step.json_id("removeNodeMark", RemoveNodeMarkStep) diff --git a/prosemirror/transform/replace.py b/prosemirror/transform/replace.py index 7507531..0a41ffd 100644 --- a/prosemirror/transform/replace.py +++ b/prosemirror/transform/replace.py @@ -116,8 +116,22 @@ def fit(self) -> Optional[Step]: return None def find_fittable(self) -> Optional[_Fittable]: + start_depth = self.unplaced.open_start + cur = self.unplaced.content + open_end = self.unplaced.open_end + for d in range(start_depth): + node = cur.first_child + if cur.child_count > 1: + open_end = 0 + if node.type.spec.get("isolating") and open_end <= d: + start_depth = d + break + cur = node.content + for pass_ in [1, 2]: - for slice_depth in range(self.unplaced.open_start, -1, -1): + for slice_depth in range( + start_depth if pass_ == 1 else self.unplaced.open_start, -1, -1 + ): if slice_depth: parent = content_at( self.unplaced.content, slice_depth - 1 diff --git a/prosemirror/transform/transform.py b/prosemirror/transform/transform.py index a52e045..1f50351 100644 --- a/prosemirror/transform/transform.py +++ b/prosemirror/transform/transform.py @@ -1,11 +1,11 @@ from typing import Union -from prosemirror.model import Fragment, MarkType, Node, NodeType, Slice +from prosemirror.model import Fragment, Mark, MarkType, Node, NodeType, Slice from . import replace, structure from .attr_step import AttrStep from .map import Mapping -from .mark_step import AddMarkStep, RemoveMarkStep +from .mark_step import AddMarkStep, AddNodeMarkStep, RemoveMarkStep, RemoveNodeMarkStep from .replace import close_fragment, covered_depths, fits_trivially, replace_step from .replace_step import ReplaceAroundStep, ReplaceStep from .structure import can_change_type, insert_point @@ -460,6 +460,19 @@ def set_node_markup(self, pos, type, attrs, marks=None): def set_node_attribute(self, pos, attr, value): return self.step(AttrStep(pos, attr, value)) + def add_node_mark(self, pos, mark): + return self.step(AddNodeMarkStep(pos, mark)) + + def remove_node_mark(self, pos, mark): + if not isinstance(mark, Mark): + node = self.doc.node_at(pos) + if not node: + raise ValueError("No node at position " + pos) + mark = mark.is_in_set(node.marks) + if not mark: + return self + return self.step(RemoveNodeMarkStep(pos, mark)) + def split(self, pos, depth=None, types_after=None): if depth is None: depth = 1 diff --git a/pyproject.toml b/pyproject.toml index 598976e..9466db0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ pydash = "^5.1.2" pandoc = "^2.3" pytest-cov = "^4.0.0" isort = "^5.11.4" -codecov = "^2.1.12" +codecov = "^2.1.13" coverage = "^7.0.5" ruff = "^0.0.230" diff --git a/tests/prosemirror_transform/tests/test_trans.py b/tests/prosemirror_transform/tests/test_trans.py index 462f28e..962c24a 100644 --- a/tests/prosemirror_transform/tests/test_trans.py +++ b/tests/prosemirror_transform/tests/test_trans.py @@ -949,6 +949,45 @@ def test_will_insert_filler_nodes_before_a_node_when_necessary(self): assert tr.doc.eq(doc(h(), b(p("One")))) +def test_keeps_isolating_nodes_together(): + s = Schema( + { + "nodes": { + **schema.spec["nodes"], + "iso": { + "group": "block", + "content": "block+", + "isolating": True, + }, + }, + } + ) + doc = s.node("doc", None, [s.node("paragraph", None, [s.text("one")])]) + iso = Fragment.from_( + s.node("iso", None, [s.node("paragraph", None, [s.text("two")])]) + ) + assert ( + Transform(doc) + .replace(2, 3, Slice(iso, 2, 0)) + .doc.eq( + s.node( + "doc", + None, + [ + s.node("paragraph", None, [s.text("o")]), + s.node("iso", None, [s.node("paragraph", None, [s.text("two")])]), + s.node("paragraph", None, [s.text("e")]), + ], + ) + ) + ) + assert ( + Transform(doc) + .replace(2, 3, Slice(iso, 2, 2)) + .doc.eq(s.node("doc", None, [s.node("paragraph", None, [s.text("otwoe")])])) + ) + + @pytest.mark.parametrize( "doc,source,expect", [ @@ -1051,3 +1090,37 @@ def test_delete_range(doc, expect, test_transform): doc.tag.get("a"), doc.tag.get("b") or doc.tag.get("a") ) test_transform(tr, expect) + + +@pytest.mark.parametrize( + "doc,mark,expect", + [ + # adds a mark + (doc(p("", img())), schema.mark("em"), doc(p("", em(img())))), + # doesn't duplicate a mark + (doc(p("", em(img()))), schema.mark("em"), doc(p("", em(img())))), + # replaces a mark + ( + doc(p("", a(img()))), + schema.mark("link", {"href": "x"}), + doc(p("", a({"href": "x"}, img()))), + ), + ], +) +def test_add_node_mark(doc, mark, expect, test_transform): + test_transform(Transform(doc).add_node_mark(doc.tag["a"], mark), expect) + + +@pytest.mark.parametrize( + "doc,mark,expect", + [ + # removes a mark + (doc(p("", em(img()))), schema.mark("em"), doc(p("", img()))), + # doesn't do anything when there is no mark + (doc(p("", img())), schema.mark("em"), doc(p("", img()))), + # can remove a mark from multiple marks + (doc(p("", em(a(img())))), schema.mark("em"), doc(p("", a(img())))), + ], +) +def test_remove_node_mark(doc, mark, expect, test_transform): + test_transform(Transform(doc).remove_node_mark(doc.tag["a"], mark), expect)