From 1eb53581cffbe7f28ba40a7184bc325a9ecd794c Mon Sep 17 00:00:00 2001 From: lastnigtic <1113594660@qq.com> Date: Thu, 10 Sep 2020 23:48:38 +0800 Subject: [PATCH 001/112] Fix missing mark when parsing nested mark with same type --- src/from_dom.js | 25 +++++++++++++++++++++++-- test/test-dom.js | 4 ++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/from_dom.js b/src/from_dom.js index 68f78e7..ca7289f 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -302,6 +302,8 @@ class NodeContext { this.activeMarks = Mark.none // Marks that can't apply here, but will be used in children if possible this.pendingMarks = pendingMarks + // Nested Marks with same type + this.stashMarks = []; } findWrapping(node) { @@ -337,6 +339,15 @@ class NodeContext { return this.type ? this.type.create(this.attrs, content, this.marks) : content } + popFromStashMark(markType) { + for (let i = this.stashMarks.length - 1; i >= 0; i--) { + if (this.stashMarks[i].type == markType) { + return this.stashMarks.splice(i, 1)[0]; + } + } + return undefined + } + applyPending(nextType) { for (let i = 0, pending = this.pendingMarks; i < pending.length; i++) { let mark = pending[i] @@ -699,6 +710,8 @@ class ParseContext { } addPendingMark(mark) { + const found = this.top.pendingMarks.findIndex(_mark => _mark.type == mark.type) + if (found > -1) this.top.stashMarks.push(mark) this.top.pendingMarks = mark.addToSet(this.top.pendingMarks) } @@ -706,8 +719,16 @@ class ParseContext { 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) + if (found > -1) { + level.pendingMarks = mark.removeFromSet(level.pendingMarks) + } else { + const stashMark = level.popFromStashMark(mark.type); + if (stashMark) { + level.activeMarks = stashMark.addToSet(level.activeMarks); + } else { + level.activeMarks = mark.removeFromSet(level.activeMarks); + } + } if (level == upto) break } } diff --git a/test/test-dom.js b/test/test-dom.js index d4e02b1..7f27921 100644 --- a/test/test-dom.js +++ b/test/test-dom.js @@ -327,6 +327,10 @@ describe("DOMParser", () => { open("
foo
bar
", [p("foo"), p(em("bar"))], 1, 1)) + it("can parse nested mark with same type", + open("

foobarbaz

", + [p(strong("foobarbaz"))], 1, 1)) + it("will not apply invalid marks to nodes", open("", [ul(li(p(strong("foo"))))], 3, 3)) From 83d39b7af7cc7a0dc030a72548fec0c19575eef5 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 11 Sep 2020 07:46:05 +0200 Subject: [PATCH 002/112] Adjust code style in 1eb53581cffbe7f28ba40a7184bc325a9ecd794c Issue #51 --- src/from_dom.js | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/from_dom.js b/src/from_dom.js index ca7289f..a552b4b 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -302,8 +302,8 @@ class NodeContext { this.activeMarks = Mark.none // Marks that can't apply here, but will be used in children if possible this.pendingMarks = pendingMarks - // Nested Marks with same type - this.stashMarks = []; + // Nested Marks with same type + this.stashMarks = [] } findWrapping(node) { @@ -340,12 +340,8 @@ class NodeContext { } popFromStashMark(markType) { - for (let i = this.stashMarks.length - 1; i >= 0; i--) { - if (this.stashMarks[i].type == markType) { - return this.stashMarks.splice(i, 1)[0]; - } - } - return undefined + for (let i = this.stashMarks.length - 1; i >= 0; i--) + if (this.stashMarks[i].type == markType) return this.stashMarks.splice(i, 1)[0] } applyPending(nextType) { @@ -710,7 +706,7 @@ class ParseContext { } addPendingMark(mark) { - const found = this.top.pendingMarks.findIndex(_mark => _mark.type == mark.type) + let found = this.top.pendingMarks.findIndex(_mark => _mark.type == mark.type) if (found > -1) this.top.stashMarks.push(mark) this.top.pendingMarks = mark.addToSet(this.top.pendingMarks) } @@ -722,12 +718,9 @@ class ParseContext { if (found > -1) { level.pendingMarks = mark.removeFromSet(level.pendingMarks) } else { - const stashMark = level.popFromStashMark(mark.type); - if (stashMark) { - level.activeMarks = stashMark.addToSet(level.activeMarks); - } else { - level.activeMarks = mark.removeFromSet(level.activeMarks); - } + level.activeMarks = mark.removeFromSet(level.activeMarks) + let stashMark = level.popFromStashMark(mark.type) + if (stashMark) level.activeMarks = stashMark.addToSet(level.activeMarks) } if (level == upto) break } From c1593e8f5f4059d791557d2770c98d87dae84aeb Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 11 Sep 2020 07:50:40 +0200 Subject: [PATCH 003/112] Add release note FIX: Fix an issue where an inner node's mark information could reset the same mark provided by an outer node in the DOM parser. From 34e3e43b813e416827a216d8d22397e5b084cd8e Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 11 Sep 2020 07:50:44 +0200 Subject: [PATCH 004/112] Mark version 1.11.1 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3240398..df47e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.11.1 (2020-09-11) + +### Bug fixes + +Fix an issue where an inner node's mark information could reset the same mark provided by an outer node in the DOM parser. + ## 1.11.0 (2020-07-08) ### New features diff --git a/package.json b/package.json index 0e24dde..a393f21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.11.0", + "version": "1.11.1", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From b8f177e8fe3a52c13ee7663fb4408ed34970e5ec Mon Sep 17 00:00:00 2001 From: lastnigtic Date: Fri, 11 Sep 2020 20:35:33 +0800 Subject: [PATCH 005/112] Fix mark with attrs missing in nested mark rule parsing --- src/from_dom.js | 4 +++- test/test-dom.js | 43 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/from_dom.js b/src/from_dom.js index a552b4b..b9a0899 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -349,6 +349,8 @@ class NodeContext { let mark = pending[i] if ((this.type ? this.type.allowsMarkType(mark.type) : markMayApply(mark.type, nextType)) && !mark.isInSet(this.activeMarks)) { + let found = this.activeMarks.findIndex(_mark => _mark.type == mark.type) + if (found > -1) this.stashMarks.push(this.activeMarks[found]) this.activeMarks = mark.addToSet(this.activeMarks) this.pendingMarks = mark.removeFromSet(this.pendingMarks) } @@ -707,7 +709,7 @@ class ParseContext { addPendingMark(mark) { let found = this.top.pendingMarks.findIndex(_mark => _mark.type == mark.type) - if (found > -1) this.top.stashMarks.push(mark) + if (found > -1) this.top.stashMarks.push(this.top.pendingMarks[found]) this.top.pendingMarks = mark.addToSet(this.top.pendingMarks) } diff --git a/test/test-dom.js b/test/test-dom.js index 7f27921..fd41439 100644 --- a/test/test-dom.js +++ b/test/test-dom.js @@ -327,16 +327,51 @@ describe("DOMParser", () => { open("
foo
bar
", [p("foo"), p(em("bar"))], 1, 1)) - it("can parse nested mark with same type", - open("

foobarbaz

", - [p(strong("foobarbaz"))], 1, 1)) - it("will not apply invalid marks to nodes", open("
  • foo
", [ul(li(p(strong("foo"))))], 3, 3)) it("will apply pending marks from parents to all children", open("
  • foo
  • bar
", [ul(li(p(strong("foo"))), li(p(strong("bar"))))], 3, 3)) + it("can parse nested mark with same type", + open("

foobarbaz

", + [p(strong("foobarbaz"))], 1, 1)) + + it("can parse nested mark with same type but different attrs", () => { + let markSchema = new Schema({ + nodes: schema.spec.nodes, + marks: schema.spec.marks.update("s", { + attrs: { + 'data-s': { default: 'tag' } + }, + parseDOM: [{ + tag: "s", + }, { + style: "text-decoration", + getAttrs() { + return { + 'data-s': 'style' + } + } + }] + }) + }) + let b = builders(markSchema); + let dom = document.createElement("div") + dom.innerHTML = "

foo

" + let result = DOMParser.fromSchema(markSchema).parseSlice(dom) + ist(result, new Slice(Fragment.from( + b.schema.nodes.paragraph.create( + undefined, + [ + b.schema.text('f', [b.schema.marks.s.create({ 'data-s': 'style' })]), + b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'tag' })]), + b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]) + ] + ), + ), 1, 1), eq) + }) + function find(html, doc) { return () => { let dom = document.createElement("div") From c7c65eaefc600be7a024fcdeb15cf30b8c6f9354 Mon Sep 17 00:00:00 2001 From: lastnigtic <1113594660@qq.com> Date: Sat, 12 Sep 2020 19:20:16 +0800 Subject: [PATCH 006/112] Fix findIndex is not supported in IE (#53) Co-authored-by: lastnigtic FIX: Fix issue where 1.11.1 uses an array method not available on Internet Explorer. --- src/from_dom.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/from_dom.js b/src/from_dom.js index b9a0899..539d7a9 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -349,8 +349,8 @@ class NodeContext { let mark = pending[i] if ((this.type ? this.type.allowsMarkType(mark.type) : markMayApply(mark.type, nextType)) && !mark.isInSet(this.activeMarks)) { - let found = this.activeMarks.findIndex(_mark => _mark.type == mark.type) - if (found > -1) this.stashMarks.push(this.activeMarks[found]) + let found = findSameTypeInSet(mark, this.activeMarks); + if (found) this.stashMarks.push(found) this.activeMarks = mark.addToSet(this.activeMarks) this.pendingMarks = mark.removeFromSet(this.pendingMarks) } @@ -708,8 +708,8 @@ class ParseContext { } addPendingMark(mark) { - let found = this.top.pendingMarks.findIndex(_mark => _mark.type == mark.type) - if (found > -1) this.top.stashMarks.push(this.top.pendingMarks[found]) + let found = findSameTypeInSet(mark, this.top.pendingMarks) + if (found) this.top.stashMarks.push(found) this.top.pendingMarks = mark.addToSet(this.top.pendingMarks) } @@ -784,3 +784,9 @@ function markMayApply(markType, nodeType) { if (scan(parent.contentMatch)) return true } } + +function findSameTypeInSet(mark, set) { + for (let i = 0; i < set.length; i++) { + if (mark.type == set[i].type) return set[i] + } +} From bd0810832d3b07a707872db43255fe2b7f49f99c Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sat, 12 Sep 2020 13:20:26 +0200 Subject: [PATCH 007/112] Mark version 1.11.2 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df47e07..74ccffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.11.2 (2020-09-12) + +### Bug fixes + +Fix issue where 1.11.1 uses an array method not available on Internet Explorer. + ## 1.11.1 (2020-09-11) ### Bug fixes diff --git a/package.json b/package.json index a393f21..2014686 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.11.1", + "version": "1.11.2", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From 4d62ca23306728fafb4c34476741af952824f2c4 Mon Sep 17 00:00:00 2001 From: Xheldon Date: Wed, 23 Sep 2020 12:44:29 +0800 Subject: [PATCH 008/112] fix typo --- src/from_dom.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/from_dom.js b/src/from_dom.js index 539d7a9..1920709 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -57,7 +57,7 @@ import {Mark} from "./mark" // A CSS property name to match. When given, this rule matches // inline styles that list that property. May also have the form // `"property=value"`, in which case the rule only matches if the -// propery's value exactly matches the given value. (For more +// 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.) // From 9df6d800703abbf920ad7e60cb01b87eb0e735aa Mon Sep 17 00:00:00 2001 From: Daniel Playfair Cal Date: Wed, 30 Sep 2020 11:18:03 +1000 Subject: [PATCH 009/112] chore: fix linter errors --- src/from_dom.js | 2 +- test/test-dom.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/from_dom.js b/src/from_dom.js index 1920709..2bb1335 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -349,7 +349,7 @@ class NodeContext { let mark = pending[i] if ((this.type ? this.type.allowsMarkType(mark.type) : markMayApply(mark.type, nextType)) && !mark.isInSet(this.activeMarks)) { - let found = findSameTypeInSet(mark, this.activeMarks); + let found = findSameTypeInSet(mark, this.activeMarks) if (found) this.stashMarks.push(found) this.activeMarks = mark.addToSet(this.activeMarks) this.pendingMarks = mark.removeFromSet(this.pendingMarks) diff --git a/test/test-dom.js b/test/test-dom.js index fd41439..047b3ff 100644 --- a/test/test-dom.js +++ b/test/test-dom.js @@ -356,7 +356,7 @@ describe("DOMParser", () => { }] }) }) - let b = builders(markSchema); + let b = builders(markSchema) let dom = document.createElement("div") dom.innerHTML = "

foo

" let result = DOMParser.fromSchema(markSchema).parseSlice(dom) @@ -368,7 +368,7 @@ describe("DOMParser", () => { b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'tag' })]), b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]) ] - ), + ) ), 1, 1), eq) }) From 9ed7f47f88be143fedb2d9b1092a2640ec658e55 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 11 Oct 2020 14:57:34 +0200 Subject: [PATCH 010/112] Allow DOMOutputSpec values to already be {dom, contentDOM} objects FEATURE: The output of `toDOM` functions can now be a `{dom, contentDOM}` object specifying the precise parent and content DOM elements. See https://discuss.prosemirror.net/t/contentdom-when-returning-a-dom-node-from-todom/3209 --- src/to_dom.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/to_dom.js b/src/to_dom.js index ca3dc96..c8a54dd 100644 --- a/src/to_dom.js +++ b/src/to_dom.js @@ -1,7 +1,7 @@ // DOMOutputSpec:: interface // 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, or an array. +// itself, a `{dom: Node, contentDOM: ?Node}` 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 @@ -121,6 +121,8 @@ export class DOMSerializer { return {dom: doc.createTextNode(structure)} if (structure.nodeType != null) return {dom: structure} + if (structure.dom && structure.dom.nodeType != null) + return structure let tagName = structure[0], space = tagName.indexOf(" ") if (space > 0) { xmlNS = tagName.slice(0, space) From 31cf53a479b9fff10e8715ee4f45883e5c5c9a33 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 11 Oct 2020 14:57:43 +0200 Subject: [PATCH 011/112] Mark version 1.12.0 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ccffa..19ca78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.12.0 (2020-10-11) + +### New features + +The output of `toDOM` functions can now be a `{dom, contentDOM}` object specifying the precise parent and content DOM elements. + ## 1.11.2 (2020-09-12) ### Bug fixes diff --git a/package.json b/package.json index 2014686..bc3c8bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.11.2", + "version": "1.12.0", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From 5564b0e966bf49648bc98b28b0055996447dafaa Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 1 Dec 2020 14:12:58 +0100 Subject: [PATCH 012/112] Make rollup.config.js more Windows-compatible Issue prosemirror/prosemirror#1108 --- rollup.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rollup.config.js b/rollup.config.js index 156b12a..caa106f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -10,5 +10,5 @@ module.exports = { sourcemap: true }], plugins: [require('@rollup/plugin-buble')()], - external(id) { return !/^[\.\/]/.test(id) } + external(id) { return id[0] != "." && !require("path").isAbsolute(id) } } From d5a82e5ab71d66ebc5d9066077195c4df62feb13 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 11 Dec 2020 09:17:20 +0100 Subject: [PATCH 013/112] Implement nonconsuming parse rules (RFC 11) See https://discuss.prosemirror.net/t/underline-parserule-that-excludes-links/3302/9 See https://github.com/ProseMirror/rfcs/blob/master/text/0011-nonconsuming-parse-rules.md FEATURE: Parse rules can now have a `consuming: false` property which allows other rules to match their tag or style even when they apply. --- src/from_dom.js | 43 ++++++++++++++++++++++++++++--------------- test/test-dom.js | 12 ++++++++++++ 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/from_dom.js b/src/from_dom.js index 2bb1335..550be02 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -68,6 +68,12 @@ import {Mark} from "./mark" // property is only meaningful in a schema—when directly // constructing a parser, the order of the rule array is used. // +// consuming:: ?boolean +// 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. +// // context:: ?string // When given, restricts this rule to only match when the current // context—the parent nodes into which the content is being @@ -189,8 +195,8 @@ export class DOMParser { return Slice.maxOpen(context.finish()) } - matchTag(dom, context) { - for (let i = 0; i < this.tags.length; i++) { + matchTag(dom, context, after) { + 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.namespaceURI == rule.namespace) && @@ -205,8 +211,8 @@ export class DOMParser { } } - matchStyle(prop, value, context) { - for (let i = 0; i < this.styles.length; i++) { + matchStyle(prop, value, context, after) { + for (let i = after ? this.styles.indexOf(after) + 1 : 0; i < this.styles.length; i++) { let rule = this.styles[i] if (rule.style.indexOf(prop) != 0 || rule.context && !context.matchesContext(rule.context) || @@ -429,13 +435,14 @@ class ParseContext { } } - // : (dom.Element) + // : (dom.Element, ?ParseRule) // 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) { - let name = dom.nodeName.toLowerCase() + addElement(dom, matchAfter) { + let name = dom.nodeName.toLowerCase(), ruleID if (listTags.hasOwnProperty(name) && this.parser.normalizeLists) normalizeList(dom) - let rule = (this.options.ruleFromNode && this.options.ruleFromNode(dom)) || this.parser.matchTag(dom, this) + 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) } else if (!rule || rule.skip || rule.closeParent) { @@ -453,7 +460,7 @@ class ParseContext { if (sync) this.sync(top) this.needsBlock = oldNeedsBlock } else { - this.addElementByRule(dom, rule) + this.addElementByRule(dom, rule, rule.consuming === false ? ruleID : null) } } @@ -468,11 +475,15 @@ class ParseContext { // had a rule with `ignore` set. readStyles(styles) { let marks = Mark.none - for (let i = 0; i < styles.length; i += 2) { - let rule = this.parser.matchStyle(styles[i], styles[i + 1], this) - if (!rule) continue - if (rule.ignore) return null - marks = this.parser.schema.marks[rule.mark].create(rule.attrs).addToSet(marks) + style: for (let i = 0; i < styles.length; i += 2) { + for (let after = null;;) { + let rule = this.parser.matchStyle(styles[i], styles[i + 1], this, after) + if (!rule) continue style + if (rule.ignore) return null + marks = this.parser.schema.marks[rule.mark].create(rule.attrs).addToSet(marks) + if (rule.consuming === false) after = rule + else break + } } return marks } @@ -481,7 +492,7 @@ class ParseContext { // 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, rule) { + addElementByRule(dom, rule, continueAfter) { let sync, nodeType, markType, mark if (rule.node) { nodeType = this.parser.schema.nodes[rule.node] @@ -499,6 +510,8 @@ class ParseContext { 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)) diff --git a/test/test-dom.js b/test/test-dom.js index 047b3ff..0d7d862 100644 --- a/test/test-dom.js +++ b/test/test-dom.js @@ -478,6 +478,18 @@ describe("DOMParser", () => { let closeParser = new DOMParser(schema, [{tag: "br", closeParent: true}].concat(DOMParser.schemaRules(schema))) ist(closeParser.parse(domFrom("

one
two

")), doc(p("one"), p("two")), eq) }) + + it("supports non-consuming node rules", () => { + let parser = new DOMParser(schema, [{tag: "ol", consuming: false, node: "blockquote"}] + .concat(DOMParser.schemaRules(schema))) + ist(parser.parse(domFrom("

    one

")), doc(blockquote(ol(li(p("one"))))), eq) + }) + + it("supports non-consuming style rules", () => { + let parser = new DOMParser(schema, [{style: "font-weight", consuming: false, mark: "em"}] + .concat(DOMParser.schemaRules(schema))) + ist(parser.parse(domFrom("

one

")), doc(p(em(strong("one")))), eq) + }) }) describe("schemaRules", () => { From cc53b08ac966d278f840966771b3144fe3df7ea3 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 11 Dec 2020 09:17:23 +0100 Subject: [PATCH 014/112] Mark version 1.13.0 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19ca78a..66a795a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.13.0 (2020-12-11) + +### New features + +Parse rules can now have a `consuming: false` property which allows other rules to match their tag or style even when they apply. + ## 1.12.0 (2020-10-11) ### New features diff --git a/package.json b/package.json index bc3c8bc..c907180 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.12.0", + "version": "1.13.0", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From ae6ab85f5296f1d880bc3d5853b2db5a6a5b2f6c Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 18 Dec 2020 09:19:04 +0100 Subject: [PATCH 015/112] Fix dropping of whitespace at start of content expression See https://discuss.prosemirror.net/t/i-doubt-there-is-a-typo/3355 --- src/content.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content.js b/src/content.js index af15e32..1963626 100644 --- a/src/content.js +++ b/src/content.js @@ -167,7 +167,7 @@ class TokenStream { this.pos = 0 this.tokens = string.split(/\s*(?=\b|\W|$)/) if (this.tokens[this.tokens.length - 1] == "") this.tokens.pop() - if (this.tokens[0] == "") this.tokens.unshift() + if (this.tokens[0] == "") this.tokens.shift() } get next() { return this.tokens[this.pos] } From 6ab3beb5ba420ef6dc493b95a10d39327232255c Mon Sep 17 00:00:00 2001 From: lastnigtic Date: Sun, 20 Dec 2020 18:44:57 +0800 Subject: [PATCH 016/112] Fix incorrectly applied to other nodes while parsing nested same mark type --- src/from_dom.js | 14 ++++++-------- test/test-dom.js | 20 +++++++++++++++++--- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/from_dom.js b/src/from_dom.js index 550be02..b79a203 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -345,9 +345,9 @@ class NodeContext { return this.type ? this.type.create(this.attrs, content, this.marks) : content } - popFromStashMark(markType) { + popFromStashMark(mark) { for (let i = this.stashMarks.length - 1; i >= 0; i--) - if (this.stashMarks[i].type == markType) return this.stashMarks.splice(i, 1)[0] + if (mark.eq(this.stashMarks[i])) return this.stashMarks.splice(i, 1)[0] } applyPending(nextType) { @@ -355,8 +355,6 @@ class NodeContext { let mark = pending[i] if ((this.type ? this.type.allowsMarkType(mark.type) : markMayApply(mark.type, nextType)) && !mark.isInSet(this.activeMarks)) { - let found = findSameTypeInSet(mark, this.activeMarks) - if (found) this.stashMarks.push(found) this.activeMarks = mark.addToSet(this.activeMarks) this.pendingMarks = mark.removeFromSet(this.pendingMarks) } @@ -721,7 +719,7 @@ class ParseContext { } addPendingMark(mark) { - let found = findSameTypeInSet(mark, this.top.pendingMarks) + let found = findSameMarkInSet(mark, this.top.pendingMarks) if (found) this.top.stashMarks.push(found) this.top.pendingMarks = mark.addToSet(this.top.pendingMarks) } @@ -734,7 +732,7 @@ class ParseContext { level.pendingMarks = mark.removeFromSet(level.pendingMarks) } else { level.activeMarks = mark.removeFromSet(level.activeMarks) - let stashMark = level.popFromStashMark(mark.type) + let stashMark = level.popFromStashMark(mark) if (stashMark) level.activeMarks = stashMark.addToSet(level.activeMarks) } if (level == upto) break @@ -798,8 +796,8 @@ function markMayApply(markType, nodeType) { } } -function findSameTypeInSet(mark, set) { +function findSameMarkInSet(mark, set) { for (let i = 0; i < set.length; i++) { - if (mark.type == set[i].type) return set[i] + if (mark.eq(set[i])) return set[i] } } diff --git a/test/test-dom.js b/test/test-dom.js index 0d7d862..cfc7d04 100644 --- a/test/test-dom.js +++ b/test/test-dom.js @@ -344,6 +344,7 @@ describe("DOMParser", () => { attrs: { 'data-s': { default: 'tag' } }, + excludes: '', parseDOM: [{ tag: "s", }, { @@ -358,18 +359,31 @@ describe("DOMParser", () => { }) let b = builders(markSchema) let dom = document.createElement("div") - dom.innerHTML = "

foo

" + dom.innerHTML = "

ooo

" let result = DOMParser.fromSchema(markSchema).parseSlice(dom) ist(result, new Slice(Fragment.from( b.schema.nodes.paragraph.create( undefined, [ - b.schema.text('f', [b.schema.marks.s.create({ 'data-s': 'style' })]), - b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'tag' })]), + b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]), + b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' }), b.schema.marks.s.create({ 'data-s': 'tag' })]), b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]) ] ) ), 1, 1), eq) + + dom.innerHTML = "

ooo

" + result = DOMParser.fromSchema(markSchema).parseSlice(dom) + ist(result, new Slice(Fragment.from( + b.schema.nodes.paragraph.create( + undefined, + [ + b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' }), b.schema.marks.s.create({ 'data-s': 'tag' })]), + b.schema.text('o', [b.schema.marks.s.create({ 'data-s': 'style' })]), + b.schema.text('o') + ] + ) + ), 1, 1), eq) }) function find(html, doc) { From d385effb5b0ea25f55f00bb54d9cb7c25ca7dbce Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 20 Dec 2020 11:56:31 +0100 Subject: [PATCH 017/112] Add release note FIX: Fix a bug where nested marks of the same type would be applied to the wrong node when parsing from DOM. From b7aa9df5a4c629bc6dbe0302c0661b204bc989fa Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 20 Dec 2020 11:56:41 +0100 Subject: [PATCH 018/112] Mark version 1.13.1 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a795a..1e4dae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.13.1 (2020-12-20) + +### Bug fixes + +Fix a bug where nested marks of the same type would be applied to the wrong node when parsing from DOM. + ## 1.13.0 (2020-12-11) ### New features diff --git a/package.json b/package.json index c907180..5a5a07f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.13.0", + "version": "1.13.1", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From 18fd890116cc852f8fabb47d930780f215a9bd46 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 2 Feb 2021 09:07:29 +0100 Subject: [PATCH 019/112] Add note about style parse rules only producing marks See https://discuss.prosemirror.net/t/css-styles-as-marks-vs-decorations/3452/3 --- src/from_dom.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/from_dom.js b/src/from_dom.js index b79a203..8acadc9 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -59,7 +59,9 @@ import {Mark} from "./mark" // `"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.) +// and return false to indicate that the match failed.) Rules +// matching styles may only produce [marks](#model.ParseRule.mark), +// not nodes. // // priority:: ?number // Can be used to change the order in which the parse rules in a From 185cf4b7d42ae8fee0605cb4a611ce0625c8e89f Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 4 Feb 2021 09:27:27 +0100 Subject: [PATCH 020/112] Make sure MarkType.removeFromSet removes _all_ instances FIX: `MarkType.removeFromSet` now removes all instances of the mark, not just the first one. Issue prosemirror/prosemirror-transform#17 --- src/schema.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/schema.js b/src/schema.js index 8152259..88fc94b 100644 --- a/src/schema.js +++ b/src/schema.js @@ -274,9 +274,10 @@ export class MarkType { // 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) { - for (var i = 0; i < set.length; i++) - if (set[i].type == this) - return set.slice(0, i).concat(set.slice(i + 1)) + 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 } From a0556b82869a7ecda732f7c4e26d42caed1a4e40 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 4 Feb 2021 09:27:34 +0100 Subject: [PATCH 021/112] Mark version 1.13.2 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e4dae5..1a0348e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.13.2 (2021-02-04) + +### Bug fixes + +`MarkType.removeFromSet` now removes all instances of the mark, not just the first one. + ## 1.13.1 (2020-12-20) ### Bug fixes diff --git a/package.json b/package.json index 5a5a07f..e1a8d50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.13.1", + "version": "1.13.2", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From 28aef8077efb73e00130fe77a45c1539cfbaf310 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 4 Feb 2021 16:44:56 +0100 Subject: [PATCH 022/112] Fix bug where the DOM parser would apply marks in invalid places FIX: Fix an issue where nested tags that match mark parser rules could cause the parser to apply marks in invalid places. Closes prosemirror/prosemirror#1130 --- src/from_dom.js | 3 ++- test/test-dom.js | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/from_dom.js b/src/from_dom.js index 8acadc9..15ba400 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -735,7 +735,8 @@ class ParseContext { } else { level.activeMarks = mark.removeFromSet(level.activeMarks) let stashMark = level.popFromStashMark(mark) - if (stashMark) level.activeMarks = stashMark.addToSet(level.activeMarks) + if (stashMark && level.type && level.type.allowsMarkType(stashMark.type)) + level.activeMarks = stashMark.addToSet(level.activeMarks) } if (level == upto) break } diff --git a/test/test-dom.js b/test/test-dom.js index cfc7d04..6eb51af 100644 --- a/test/test-dom.js +++ b/test/test-dom.js @@ -504,6 +504,10 @@ describe("DOMParser", () => { .concat(DOMParser.schemaRules(schema))) ist(parser.parse(domFrom("

one

")), doc(p(em(strong("one")))), eq) }) + + it("doesn't get confused by nested mark tags", + recover("
AB
C", + doc(p(strong("A"), "B"), p("C")))) }) describe("schemaRules", () => { From 67d65f21b96c6a0635b7b85067856f649a678fd4 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 4 Feb 2021 16:45:04 +0100 Subject: [PATCH 023/112] Mark version 1.13.3 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0348e..a39c2ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.13.3 (2021-02-04) + +### Bug fixes + +Fix an issue where nested tags that match mark parser rules could cause the parser to apply marks in invalid places. + ## 1.13.2 (2021-02-04) ### Bug fixes diff --git a/package.json b/package.json index e1a8d50..447f251 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.13.2", + "version": "1.13.3", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From 024384b36331ec69d7842b35af2d011bfacac837 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 19 Feb 2021 13:25:19 +0100 Subject: [PATCH 024/112] Document Fragment.textBetween FEATURE: `Fragment.textBetween` is now public. --- src/fragment.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fragment.js b/src/fragment.js index d1e27d7..7badf09 100644 --- a/src/fragment.js +++ b/src/fragment.js @@ -40,7 +40,9 @@ export class Fragment { this.nodesBetween(0, this.size, f) } - // : (number, number, ?string, ?string) → string + // :: (number, number, ?string, ?string) → string + // Extract the text between `from` and `to`. See the same method on + // [`Node`](#model.Node.textBetween). textBetween(from, to, blockSeparator, leafText) { let text = "", separated = true this.nodesBetween(from, to, (node, pos) => { From 06a044abfe64d57e0d1c8c1a5a7a914386530b84 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 22 Feb 2021 17:02:47 +0100 Subject: [PATCH 025/112] Make Node.check check mark set validity FIX: `Node.check` will now error if a node has an invalid combination of marks. Closes prosemirror/prosemirror#1116 --- src/node.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/node.js b/src/node.js index 1b91350..9763c74 100644 --- a/src/node.js +++ b/src/node.js @@ -327,6 +327,10 @@ export class Node { check() { if (!this.type.validContent(this.content)) throw new RangeError(`Invalid content for node ${this.type.name}: ${this.content.toString().slice(0, 50)}`) + 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()) } From eef20c8c6dbf841b1d70859df5d59c21b5108a4f Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 10 Mar 2021 12:23:59 +0100 Subject: [PATCH 026/112] Remove carriage return characters from parsed DOM content Even when preserveWhitespace is "full". FIX: Don't leave carriage return characters in parsed DOM content, since they confuse Chrome's cursor motion. Issue prosemirror/prosemirror#1138 --- src/from_dom.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/from_dom.js b/src/from_dom.js index 15ba400..3279e41 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -427,6 +427,8 @@ class ParseContext { } } 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) From 41b48ab4845fea1166c0b1e0f6e042cba84e60f1 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 6 Apr 2021 14:15:09 +0200 Subject: [PATCH 027/112] Make sure ignored BR nodes create an inline context Issue prosemirror/prosemirror#1148 --- src/from_dom.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/from_dom.js b/src/from_dom.js index 3279e41..70c16af 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -447,6 +447,7 @@ class ParseContext { (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.nodeType) dom = rule.skip @@ -472,6 +473,13 @@ class ParseContext { this.addTextNode(dom.ownerDocument.createTextNode("\n")) } + // Called for ignored nodes + ignoreFallback(dom) { + // 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. From 71c174d3b8fcbbe7628dd704e05f9a5787807e06 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 6 Apr 2021 14:27:51 +0200 Subject: [PATCH 028/112] Mark version 1.14.0 --- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a39c2ee..76fa656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 1.14.0 (2021-04-06) + +### Bug fixes + +`Node.check` will now error if a node has an invalid combination of marks. + +Don't leave carriage return characters in parsed DOM content, since they confuse Chrome's cursor motion. + +### New features + +`Fragment.textBetween` is now public. + ## 1.13.3 (2021-02-04) ### Bug fixes diff --git a/package.json b/package.json index 447f251..2919895 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.13.3", + "version": "1.14.0", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From f28b89a16eb9f9c1b6aa6174a7981668d2a54693 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 26 Apr 2021 07:59:56 +0200 Subject: [PATCH 029/112] Don't ignore whitespace-only modes in DOM parsing when preserveWhitespace is full FIX: DOM parsing with `preserveWhitespace: "full"` will no longer ignore whitespace-only nodes. Issue https://github.com/ProseMirror/prosemirror/issues/1159 --- src/from_dom.js | 4 +++- test/test-dom.js | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/from_dom.js b/src/from_dom.js index 70c16af..7f426de 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -411,7 +411,9 @@ class ParseContext { addTextNode(dom) { let value = dom.nodeValue let top = this.top - if ((top.type ? top.type.inlineContent : top.content.length && top.content[0].isInline) || /[^ \t\r\n\u000c]/.test(value)) { + if (top.options & OPT_PRESERVE_WS_FULL || + (top.type ? top.type.inlineContent : top.content.length && top.content[0].isInline) || + /[^ \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 diff --git a/test/test-dom.js b/test/test-dom.js index 6eb51af..3841048 100644 --- a/test/test-dom.js +++ b/test/test-dom.js @@ -270,6 +270,9 @@ describe("DOMParser", () => { recover("

xxbar

", doc(p(strong("xxbar"))))) + it("doesn't ignore whitespace-only nodes in preserveWhitespace full mode", + recover(" x", doc(p(" x")), {preserveWhitespace: "full"})) + function parse(html, options, doc) { return () => { let dom = document.createElement("div") From 3c0b054fbdeabbf45836b3441ec8ce5da8da2e5d Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 26 Apr 2021 08:00:13 +0200 Subject: [PATCH 030/112] Mark version 1.14.1 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fa656..3c4a99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.14.1 (2021-04-26) + +### Bug fixes + +DOM parsing with `preserveWhitespace: "full"` will no longer ignore whitespace-only nodes. + ## 1.14.0 (2021-04-06) ### Bug fixes diff --git a/package.json b/package.json index 2919895..bc548a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.14.0", + "version": "1.14.1", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From 9ef574650462ceaf6f16dee3e8bb7ac502f2bf18 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 16 Jun 2021 10:39:52 +0200 Subject: [PATCH 031/112] More aggressively assign inline context in parseSlice FIX: Be less agressive about dropping whitespace when the context isn't know in `DOMParser.parseSlice`. Issue https://github.com/ProseMirror/prosemirror/issues/1182 --- src/from_dom.js | 8 +++++++- test/test-dom.js | 10 ++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/from_dom.js b/src/from_dom.js index 7f426de..5dff8ca 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -362,6 +362,12 @@ class NodeContext { } } } + + inlineContext(node) { + 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 { @@ -412,7 +418,7 @@ class ParseContext { let value = dom.nodeValue let top = this.top if (top.options & OPT_PRESERVE_WS_FULL || - (top.type ? top.type.inlineContent : top.content.length && top.content[0].isInline) || + top.inlineContext(dom) || /[^ \t\r\n\u000c]/.test(value)) { if (!(top.options & OPT_PRESERVE_WS)) { value = value.replace(/[ \t\r\n\u000c]+/g, " ") diff --git a/test/test-dom.js b/test/test-dom.js index 3841048..c8ef509 100644 --- a/test/test-dom.js +++ b/test/test-dom.js @@ -299,11 +299,11 @@ describe("DOMParser", () => { parse("foo bar", {preserveWhitespace: true}, doc(p("foo bar")))) - function open(html, nodes, openStart, openEnd) { + function open(html, nodes, openStart, openEnd, options) { return () => { let dom = document.createElement("div") dom.innerHTML = html - let result = parser.parseSlice(dom) + let result = parser.parseSlice(dom, options) ist(result, new Slice(Fragment.from(nodes.map(n => typeof n == "string" ? schema.text(n) : n)), openStart, openEnd), eq) } } @@ -340,6 +340,12 @@ describe("DOMParser", () => { open("

foobarbaz

", [p(strong("foobarbaz"))], 1, 1)) + it("drops block-level whitespace", + open("
", [], 0, 0, {preserveWhitespace: true})) + + it("keeps whitespace in inline elements", + open(" ", [p(strong(" ")).child(0)], 0, 0, {preserveWhitespace: true})) + it("can parse nested mark with same type but different attrs", () => { let markSchema = new Schema({ nodes: schema.spec.nodes, From c1119ee4a7cd0e8e8389fb09fd8005c3eabc3e05 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 16 Jun 2021 10:40:02 +0200 Subject: [PATCH 032/112] Mark version 1.14.2 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4a99a..8c751cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.14.2 (2021-06-16) + +### Bug fixes + +Be less agressive about dropping whitespace when the context isn't know in `DOMParser.parseSlice`. + ## 1.14.1 (2021-04-26) ### Bug fixes diff --git a/package.json b/package.json index bc548a0..689a709 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.14.1", + "version": "1.14.2", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From cf06d61efd6c44938e7d4a145d0c31398953912d Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 22 Jul 2021 11:06:21 +0200 Subject: [PATCH 033/112] Make serializeNode include marks FIX: `DOMSerializer.serializeNode` will no longer ignore the node's marks. --- src/to_dom.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/to_dom.js b/src/to_dom.js index c8a54dd..4e617f0 100644 --- a/src/to_dom.js +++ b/src/to_dom.js @@ -69,19 +69,13 @@ export class DOMSerializer { } } } - top.appendChild(this.serializeNode(node, options)) + top.appendChild(this.serializeNodeInner(node, options)) }) return target } - // :: (Node, ?Object) → dom.Node - // 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, options = {}) { + serializeNodeInner(node, options = {}) { let {dom, contentDOM} = DOMSerializer.renderSpec(doc(options), this.nodes[node.type.name](node)) if (contentDOM) { @@ -95,8 +89,14 @@ export class DOMSerializer { return dom } - serializeNodeAndMarks(node, options = {}) { - let dom = this.serializeNode(node, options) + // :: (Node, ?Object) → dom.Node + // 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, options = {}) { + 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) { From 5ff774fa853dd63ecf30e42ff487b111b3c1c5fd Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 22 Jul 2021 11:06:39 +0200 Subject: [PATCH 034/112] Mark version 1.14.3 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c751cf..3978e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.14.3 (2021-07-22) + +### Bug fixes + +`DOMSerializer.serializeNode` will no longer ignore the node's marks. + ## 1.14.2 (2021-06-16) ### Bug fixes diff --git a/package.json b/package.json index 689a709..3e190cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.14.2", + "version": "1.14.3", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From ab627a1bfde5befc23b52c494e0d30d75579e291 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 15 Oct 2021 09:25:55 +0200 Subject: [PATCH 035/112] Add an .npmrc that turns off lock files --- .npmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false From babdac81c9b4863c40ee85eb46569df6d7d583a8 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 15 Oct 2021 09:27:38 +0200 Subject: [PATCH 036/112] Upgrade Mocha --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3e190cd..448ff5f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "orderedmap": "^1.1.0" }, "devDependencies": { - "mocha": "^3.0.2", + "mocha": "^9.1.2", "ist": "^1.0.0", "jsdom": "^10.1.0", "prosemirror-test-builder": "^1.0.0", From 039a2ee3c411a85f0bbc812454aac55423113cd9 Mon Sep 17 00:00:00 2001 From: Kushan Joshi <0o3ko0@gmail.com> Date: Mon, 25 Oct 2021 02:28:22 -0400 Subject: [PATCH 037/112] Extend textBetween API by accepting function to stringify leaf nodes FEATURE: `textBetween` now allows its leaf text argument to be a function. --- src/fragment.js | 4 ++-- src/node.js | 2 +- test/test-node.js | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/fragment.js b/src/fragment.js index 7badf09..8eae0e4 100644 --- a/src/fragment.js +++ b/src/fragment.js @@ -40,7 +40,7 @@ export class Fragment { this.nodesBetween(0, this.size, f) } - // :: (number, number, ?string, ?string) → string + // :: (number, number, ?string, ?string | ?(leafNode: Node) -> string) → string // Extract the text between `from` and `to`. See the same method on // [`Node`](#model.Node.textBetween). textBetween(from, to, blockSeparator, leafText) { @@ -50,7 +50,7 @@ export class Fragment { text += node.text.slice(Math.max(from, pos) - pos, to - pos) separated = !blockSeparator } else if (node.isLeaf && leafText) { - text += leafText + text += typeof leafText === 'function' ? leafText(node): leafText separated = !blockSeparator } else if (!separated && node.isBlock) { text += blockSeparator diff --git a/src/node.js b/src/node.js index 9763c74..5552be6 100644 --- a/src/node.js +++ b/src/node.js @@ -93,7 +93,7 @@ export class Node { // children. get textContent() { return this.textBetween(0, this.content.size, "") } - // :: (number, number, ?string, ?string) → string + // :: (number, number, ?string, ?string | ?(leafNode: Node) -> string) → string // Get all text between positions `from` and `to`. When // `blockSeparator` is given, it will be inserted whenever a new // block node is started. When `leafText` is given, it'll be diff --git a/test/test-node.js b/test/test-node.js index 35335ed..69a2b96 100644 --- a/test/test-node.js +++ b/test/test-node.js @@ -86,6 +86,20 @@ describe("Node", () => { "paragraph", "foo", "bar", "image", "baz", "hard_break", "quux", "xyz")) }) + describe("textBetween", () => { + it("works when passing a custom function as leafText", () => { + const d = doc(p("foo", img, br)) + ist(d.textBetween(0, d.content.size, '', (node) => { + if (node.type.name === 'image') { + return '' + } + if (node.type.name === 'hard_break') { + return '' + } + }), 'foo') + }) + }) + describe("textContent", () => { it("works on a whole doc", () => { ist(doc(p("foo")).textContent, "foo") From ce7fbbba5b023a3cfe7c74d14667611eb93593ee Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 25 Oct 2021 08:29:43 +0200 Subject: [PATCH 038/112] Mark version 1.15.0 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3978e52..78d2757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.15.0 (2021-10-25) + +### New features + +`textBetween` now allows its leaf text argument to be a function. + ## 1.14.3 (2021-07-22) ### Bug fixes diff --git a/package.json b/package.json index 448ff5f..4b8d6e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.14.3", + "version": "1.15.0", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From 428a8ba876558e248629d66573ed4f736db9c3e1 Mon Sep 17 00:00:00 2001 From: Jacob Easley <31745594+jacobez@users.noreply.github.com> Date: Wed, 3 Nov 2021 08:41:20 -0600 Subject: [PATCH 039/112] Add index parameter to documentation for Node.descendants --- src/node.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node.js b/src/node.js index 5552be6..784a2bb 100644 --- a/src/node.js +++ b/src/node.js @@ -81,7 +81,7 @@ export class Node { this.content.nodesBetween(from, to, f, startPos, this) } - // :: ((node: Node, pos: number, parent: Node) → ?bool) + // :: ((node: Node, pos: number, parent: Node, index: number) → ?bool) // Call the given callback for every descendant node. Doesn't // descend into a node when the callback returns `false`. descendants(f) { From 23221300428f1b485cb17d86bb1bd72570096647 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 12 Nov 2021 09:26:43 +0100 Subject: [PATCH 040/112] Fix type comments --- src/fragment.js | 2 +- src/node.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fragment.js b/src/fragment.js index 8eae0e4..101f2b1 100644 --- a/src/fragment.js +++ b/src/fragment.js @@ -40,7 +40,7 @@ export class Fragment { this.nodesBetween(0, this.size, f) } - // :: (number, number, ?string, ?string | ?(leafNode: Node) -> string) → string + // :: (number, number, ?string, ?union) → string // Extract the text between `from` and `to`. See the same method on // [`Node`](#model.Node.textBetween). textBetween(from, to, blockSeparator, leafText) { diff --git a/src/node.js b/src/node.js index 784a2bb..3892c45 100644 --- a/src/node.js +++ b/src/node.js @@ -93,7 +93,7 @@ export class Node { // children. get textContent() { return this.textBetween(0, this.content.size, "") } - // :: (number, number, ?string, ?string | ?(leafNode: Node) -> string) → string + // :: (number, number, ?string, ?union string>) → string // Get all text between positions `from` and `to`. When // `blockSeparator` is given, it will be inserted whenever a new // block node is started. When `leafText` is given, it'll be From ffbe05be10487e612b57b433f5714f02cee06907 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 22 Dec 2021 09:06:22 +0100 Subject: [PATCH 041/112] Clarify doc comment for textBetween Issue https://github.com/ProseMirror/prosemirror/issues/1228 --- src/node.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node.js b/src/node.js index 3892c45..d3bc913 100644 --- a/src/node.js +++ b/src/node.js @@ -95,8 +95,8 @@ export class Node { // :: (number, number, ?string, ?union string>) → string // Get all text between positions `from` and `to`. When - // `blockSeparator` is given, it will be inserted whenever a new - // block node is started. When `leafText` is given, it'll be + // `blockSeparator` is given, it will be inserted to separate text + // from different block nodes. When `leafText` is given, it'll be // inserted for every non-text leaf node encountered. textBetween(from, to, blockSeparator, leafText) { return this.content.textBetween(from, to, blockSeparator, leafText) From b9085fbf195e07f336c48647a4b318c8d6133054 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 27 Dec 2021 14:10:33 +0100 Subject: [PATCH 042/112] Add NodeSpec.whitespace FEATURE: A new `NodeSpec` property, `whitespace`, allows more control over the way whitespace in the content of the node is parsed. Issue https://github.com/ProseMirror/prosemirror-view/pull/115 --- src/from_dom.js | 9 +++++---- src/schema.js | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/from_dom.js b/src/from_dom.js index 5dff8ca..d40b955 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -292,8 +292,9 @@ const listTags = {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(preserveWhitespace) { - return (preserveWhitespace ? OPT_PRESERVE_WS : 0) | (preserveWhitespace === "full" ? OPT_PRESERVE_WS_FULL : 0) +function wsOptionsFor(type, preserveWhitespace) { + return (preserveWhitespace ? OPT_PRESERVE_WS : 0) | + (preserveWhitespace === "full" || (preserveWhitespace == null && type && type.whitespace == "pre") ? OPT_PRESERVE_WS_FULL : 0) } class NodeContext { @@ -379,7 +380,7 @@ class ParseContext { this.options = options this.isOpen = open let topNode = options.topNode, topContext - let topOptions = wsOptionsFor(options.preserveWhitespace) | (open ? OPT_OPEN_LEFT : 0) + let topOptions = wsOptionsFor(null, options.preserveWhitespace) | (open ? OPT_OPEN_LEFT : 0) if (topNode) topContext = new NodeContext(topNode.type, topNode.attrs, Mark.none, Mark.none, true, options.topMatch || topNode.type.contentMatch, topOptions) @@ -621,7 +622,7 @@ class ParseContext { let top = this.top top.applyPending(type) top.match = top.match && top.match.matchType(type, attrs) - let options = preserveWS == null ? top.options & ~OPT_OPEN_LEFT : wsOptionsFor(preserveWS) + let options = preserveWS == null ? top.options & ~OPT_OPEN_LEFT : wsOptionsFor(type, preserveWS) 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++ diff --git a/src/schema.js b/src/schema.js index 88fc94b..896323f 100644 --- a/src/schema.js +++ b/src/schema.js @@ -102,6 +102,10 @@ export class NodeType { // directly editable content. get isAtom() { return this.isLeaf || this.spec.atom } + // :: union<"pre", "normal"> + // The node type's [whitespace](#view.NodeSpec.whitespace) option. + get whitespace() { return this.spec.whitespace || (this.spec.code ? "pre" : "normal") } + // :: () → bool // Tells you whether this node type has any required attributes. hasRequiredAttrs() { @@ -361,6 +365,17 @@ export class MarkType { // Can be used to indicate that this node contains code, which // causes some commands to behave differently. // +// whitespace:: ?union<"pre", "normal"> +// 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. +// // defining:: ?bool // Determines whether this node is considered an important parent // node during replace operations (such as paste). Non-defining (the From fdc81cbc1ddfb7afa482c03f2446261554be7455 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 27 Dec 2021 14:10:44 +0100 Subject: [PATCH 043/112] Mark version 1.16.0 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78d2757..d29614e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.16.0 (2021-12-27) + +### New features + +A new `NodeSpec` property, `whitespace`, allows more control over the way whitespace in the content of the node is parsed. + ## 1.15.0 (2021-10-25) ### New features diff --git a/package.json b/package.json index 4b8d6e2..b06587f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.15.0", + "version": "1.16.0", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From 2de8628d614c9dd2bcd0e6f7a002561eac40df71 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 29 Dec 2021 12:13:04 +0100 Subject: [PATCH 044/112] Fix whitespace option propagation in DOMParser FIX: Fix a bug in the way whitespace-preservation options were handled in `DOMParser`. --- src/from_dom.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/from_dom.js b/src/from_dom.js index d40b955..ffee600 100644 --- a/src/from_dom.js +++ b/src/from_dom.js @@ -292,9 +292,10 @@ const listTags = {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, preserveWhitespace) { - return (preserveWhitespace ? OPT_PRESERVE_WS : 0) | - (preserveWhitespace === "full" || (preserveWhitespace == null && type && type.whitespace == "pre") ? OPT_PRESERVE_WS_FULL : 0) +function wsOptionsFor(type, preserveWhitespace, base) { + 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 { @@ -380,7 +381,7 @@ class ParseContext { this.options = options this.isOpen = open let topNode = options.topNode, topContext - let topOptions = wsOptionsFor(null, options.preserveWhitespace) | (open ? OPT_OPEN_LEFT : 0) + let topOptions = wsOptionsFor(null, options.preserveWhitespace, 0) | (open ? OPT_OPEN_LEFT : 0) if (topNode) topContext = new NodeContext(topNode.type, topNode.attrs, Mark.none, Mark.none, true, options.topMatch || topNode.type.contentMatch, topOptions) @@ -622,7 +623,7 @@ class ParseContext { let top = this.top top.applyPending(type) top.match = top.match && top.match.matchType(type, attrs) - let options = preserveWS == null ? top.options & ~OPT_OPEN_LEFT : wsOptionsFor(type, preserveWS) + 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++ From 95298fb02744e1a8f41eae50f8a6afde583a8817 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 29 Dec 2021 12:13:24 +0100 Subject: [PATCH 045/112] Mark version 1.16.1 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d29614e..cd66d3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.16.1 (2021-12-29) + +### Bug fixes + +Fix a bug in the way whitespace-preservation options were handled in `DOMParser`. + ## 1.16.0 (2021-12-27) ### New features diff --git a/package.json b/package.json index b06587f..ff135f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.16.0", + "version": "1.16.1", "description": "ProseMirror's document model", "main": "dist/index.js", "module": "dist/index.es.js", From b8c5166e9ac5c5cf87da3f13012b0044fd8a4bd9 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 18 Mar 2022 11:57:21 +0100 Subject: [PATCH 046/112] Document the new definingForContent and definingAsContext node options Issue https://github.com/ProseMirror/prosemirror/issues/1248 --- src/schema.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/schema.js b/src/schema.js index 896323f..c375fca 100644 --- a/src/schema.js +++ b/src/schema.js @@ -376,15 +376,21 @@ export class MarkType { // the way the node is rendered—that should be handled by `toDOM` // and/or styling. // -// defining:: ?bool +// definingAsContext:: ?bool // 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. -// Likewise, 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:: ?bool +// 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. +// +// defining:: ?bool +// When enabled, enables both +// [`definingAsContext`](#model.NodeSpec.definingAsContext) and +// [`definingForContent`](#model.NodeSpec.definingForContent). // // isolating:: ?bool // When enabled (default is false), the sides of nodes of this type From fc9113fdee61c82f788b83c046545b341ddecae6 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 26 Apr 2022 09:17:10 +0200 Subject: [PATCH 047/112] Add a clarifying sentence to doc comment for Fragment.descendants See https://discuss.prosemirror.net/t/node-descendants-doc-update-request/4572 --- src/fragment.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/fragment.js b/src/fragment.js index 101f2b1..bdea44c 100644 --- a/src/fragment.js +++ b/src/fragment.js @@ -34,8 +34,9 @@ export class Fragment { } // :: ((node: Node, pos: number, parent: Node) → ?bool) - // Call the given callback for every descendant node. The callback - // may return `false` to prevent traversal of a given node's children. + // 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) { this.nodesBetween(0, this.size, f) } From c8c7b62645d2a8293fa6b7f52aa2b04a97821f34 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 4 May 2022 10:14:24 +0200 Subject: [PATCH 048/112] Port code to TypeScript --- .gitignore | 1 + etc/link-self.js | 13 - package.json | 24 +- rollup.config.js | 14 - src/README.md | 1 + src/{comparedeep.js => comparedeep.ts} | 2 +- src/content.js | 389 -------------- src/content.ts | 413 ++++++++++++++ src/{diff.js => diff.ts} | 12 +- src/dom.ts | 1 + src/fragment.js | 280 ---------- src/fragment.ts | 268 +++++++++ src/{from_dom.js => from_dom.ts} | 622 ++++++++++----------- src/{index.js => index.ts} | 6 +- src/mark.js | 116 ---- src/mark.ts | 109 ++++ src/node.js | 419 --------------- src/node.ts | 387 +++++++++++++ src/{replace.js => replace.ts} | 155 +++--- src/resolvedpos.js | 291 ---------- src/resolvedpos.ts | 279 ++++++++++ src/schema.js | 609 --------------------- src/schema.ts | 626 ++++++++++++++++++++++ src/to_dom.js | 195 ------- src/to_dom.ts | 190 +++++++ test/{test-content.js => test-content.ts} | 52 +- test/{test-diff.js => test-diff.ts} | 13 +- test/{test-dom.js => test-dom.ts} | 119 ++-- test/{test-mark.js => test-mark.ts} | 12 +- test/{test-node.js => test-node.ts} | 45 +- test/{test-replace.js => test-replace.ts} | 18 +- test/{test-resolve.js => test-resolve.ts} | 10 +- test/{test-slice.js => test-slice.ts} | 11 +- 33 files changed, 2832 insertions(+), 2870 deletions(-) delete mode 100644 etc/link-self.js delete mode 100644 rollup.config.js rename src/{comparedeep.js => comparedeep.ts} (91%) delete mode 100644 src/content.js create mode 100644 src/content.ts rename src/{diff.js => diff.ts} (71%) create mode 100644 src/dom.ts delete mode 100644 src/fragment.js create mode 100644 src/fragment.ts rename src/{from_dom.js => from_dom.ts} (57%) rename src/{index.js => index.ts} (51%) delete mode 100644 src/mark.js create mode 100644 src/mark.ts delete mode 100644 src/node.js create mode 100644 src/node.ts rename src/{replace.js => replace.ts} (60%) delete mode 100644 src/resolvedpos.js create mode 100644 src/resolvedpos.ts delete mode 100644 src/schema.js create mode 100644 src/schema.ts delete mode 100644 src/to_dom.js create mode 100644 src/to_dom.ts rename test/{test-content.js => test-content.ts} (83%) rename test/{test-diff.js => test-diff.ts} (89%) rename test/{test-dom.js => test-dom.ts} (86%) rename test/{test-mark.js => test-mark.ts} (94%) rename test/{test-node.js => test-node.ts} (79%) rename test/{test-replace.js => test-replace.ts} (85%) rename test/{test-resolve.js => test-resolve.ts} (89%) rename test/{test-slice.js => test-slice.ts} (89%) diff --git a/.gitignore b/.gitignore index e23aa65..6b7307a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /node_modules .tern-port /dist +/test/*.js diff --git a/etc/link-self.js b/etc/link-self.js deleted file mode 100644 index 19a0864..0000000 --- a/etc/link-self.js +++ /dev/null @@ -1,13 +0,0 @@ -const {lstatSync, symlinkSync, renameSync} = require("fs") - -let stats -try { - stats = lstatSync("node_modules/prosemirror-model") -} catch(_) { - return -} - -if (!stats.isSymbolicLink()) { - renameSync("node_modules/prosemirror-model", "node_modules/prosemirror-model.disabled") - symlinkSync("..", "node_modules/prosemirror-model", "dir") -} diff --git a/package.json b/package.json index ff135f5..9439fec 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,15 @@ "name": "prosemirror-model", "version": "1.16.1", "description": "ProseMirror's document model", - "main": "dist/index.js", - "module": "dist/index.es.js", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "sideEffects": false, "license": "MIT", "maintainers": [ { @@ -20,17 +27,12 @@ "orderedmap": "^1.1.0" }, "devDependencies": { - "mocha": "^9.1.2", - "ist": "^1.0.0", + "@prosemirror/buildhelper": "^0.1.5", "jsdom": "^10.1.0", - "prosemirror-test-builder": "^1.0.0", - "rollup": "^2.26.3", - "@rollup/plugin-buble": "^0.21.3" + "prosemirror-test-builder": "^1.0.0" }, "scripts": { - "test": "node etc/link-self.js && mocha test/test-*.js", - "build": "rollup -c", - "watch": "rollup -c -w", - "prepare": "npm run build" + "test": "pm-runtests", + "prepare": "pm-buildhelper src/index.ts" } } diff --git a/rollup.config.js b/rollup.config.js deleted file mode 100644 index caa106f..0000000 --- a/rollup.config.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - input: './src/index.js', - output: [{ - file: 'dist/index.js', - format: 'cjs', - sourcemap: true - }, { - file: 'dist/index.es.js', - format: 'es', - sourcemap: true - }], - plugins: [require('@rollup/plugin-buble')()], - external(id) { return id[0] != "." && !require("path").isAbsolute(id) } -} diff --git a/src/README.md b/src/README.md index 3a2951b..3aba9c8 100644 --- a/src/README.md +++ b/src/README.md @@ -11,6 +11,7 @@ describes the type of the content, and holds a @Fragment @Mark @Slice +@Attrs @ReplaceError ### Resolved Positions diff --git a/src/comparedeep.js b/src/comparedeep.ts similarity index 91% rename from src/comparedeep.js rename to src/comparedeep.ts index ac39c95..f721031 100644 --- a/src/comparedeep.js +++ b/src/comparedeep.ts @@ -1,4 +1,4 @@ -export function compareDeep(a, b) { +export function compareDeep(a: any, b: any) { if (a === b) return true if (!(a && typeof a == "object") || !(b && typeof b == "object")) return false diff --git a/src/content.js b/src/content.js deleted file mode 100644 index 1963626..0000000 --- a/src/content.js +++ /dev/null @@ -1,389 +0,0 @@ -import {Fragment} from "./fragment" - -// ::- 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 { - constructor(validEnd) { - // :: bool - // True when this match state represents a valid end of the node. - this.validEnd = validEnd - this.next = [] - this.wrapCache = [] - } - - static parse(string, nodeTypes) { - 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 - } - - // :: (NodeType) → ?ContentMatch - // Match a node type, returning a match after that node if - // successful. - matchType(type) { - for (let i = 0; i < this.next.length; i += 2) - if (this.next[i] == type) return this.next[i + 1] - return null - } - - // :: (Fragment, ?number, ?number) → ?ContentMatch - // Try to match a fragment. Returns the resulting match when - // successful. - matchFragment(frag, start = 0, end = frag.childCount) { - let cur = this - for (let i = start; cur && i < end; i++) - cur = cur.matchType(frag.child(i).type) - return cur - } - - get inlineContent() { - let first = this.next[0] - return first ? first.isInline : false - } - - // :: ?NodeType - // Get the first matching node type at this match position that can - // be generated. - get defaultType() { - for (let i = 0; i < this.next.length; i += 2) { - let type = this.next[i] - if (!(type.isText || type.hasRequiredAttrs())) return type - } - } - - compatible(other) { - for (let i = 0; i < this.next.length; i += 2) - for (let j = 0; j < other.next.length; j += 2) - if (this.next[i] == other.next[j]) return true - return false - } - - // :: (Fragment, bool, ?number) → ?Fragment - // 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, toEnd = false, startIndex = 0) { - let seen = [this] - function search(match, types) { - 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 += 2) { - let type = match.next[i], next = match.next[i + 1] - if (!(type.isText || type.hasRequiredAttrs()) && seen.indexOf(next) == -1) { - seen.push(next) - let found = search(next, types.concat(type)) - if (found) return found - } - } - } - - return search(this, []) - } - - // :: (NodeType) → ?[NodeType] - // 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) { - for (let i = 0; i < this.wrapCache.length; i += 2) - if (this.wrapCache[i] == target) return this.wrapCache[i + 1] - let computed = this.computeWrapping(target) - this.wrapCache.push(target, computed) - return computed - } - - computeWrapping(target) { - let seen = Object.create(null), active = [{match: this, type: null, via: null}] - while (active.length) { - let current = active.shift(), match = current.match - if (match.matchType(target)) { - let result = [] - for (let obj = current; obj.type; obj = obj.via) - result.push(obj.type) - return result.reverse() - } - for (let i = 0; i < match.next.length; i += 2) { - let type = match.next[i] - if (!type.isLeaf && !type.hasRequiredAttrs() && !(type.name in seen) && (!current.type || match.next[i + 1].validEnd)) { - active.push({match: type.contentMatch, type, via: current}) - seen[type.name] = true - } - } - } - } - - // :: number - // The number of outgoing edges this node has in the finite - // automaton that describes the content expression. - get edgeCount() { - return this.next.length >> 1 - } - - // :: (number) → {type: NodeType, next: ContentMatch} - // Get the _n_​th outgoing edge from this node in the finite - // automaton that describes the content expression. - edge(n) { - let i = n << 1 - if (i >= this.next.length) throw new RangeError(`There's no ${n}th edge in this content match`) - return {type: this.next[i], next: this.next[i + 1]} - } - - toString() { - let seen = [] - function scan(m) { - seen.push(m) - for (let i = 1; i < m.next.length; i += 2) - if (seen.indexOf(m.next[i]) == -1) scan(m.next[i]) - } - scan(this) - return seen.map((m, i) => { - let out = i + (m.validEnd ? "*" : " ") + " " - for (let i = 0; i < m.next.length; i += 2) - out += (i ? ", " : "") + m.next[i].name + "->" + seen.indexOf(m.next[i + 1]) - return out - }).join("\n") - } -} - -ContentMatch.empty = new ContentMatch(true) - -class TokenStream { - constructor(string, nodeTypes) { - this.string = string - this.nodeTypes = nodeTypes - this.inline = null - this.pos = 0 - 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) { return this.next == tok && (this.pos++ || true) } - - err(str) { throw new SyntaxError(str + " (in content expression '" + this.string + "')") } -} - -function parseExpr(stream) { - let exprs = [] - do { exprs.push(parseExprSeq(stream)) } - while (stream.eat("|")) - return exprs.length == 1 ? exprs[0] : {type: "choice", exprs} -} - -function parseExprSeq(stream) { - 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) { - 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) { - if (/\D/.test(stream.next)) stream.err("Expected number, got '" + stream.next + "'") - let result = Number(stream.next) - stream.pos++ - return result -} - -function parseExprRange(stream, 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, name) { - 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) { - 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} - }) - 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 - -// : (Object) → [[{term: ?any, to: number}]] -// 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) { - let nfa = [[]] - connect(compile(expr, 0), node()) - return nfa - - function node() { return nfa.push([]) - 1 } - function edge(from, to, term) { - let edge = {term, to} - nfa[from].push(edge) - return edge - } - function connect(edges, to) { edges.forEach(edge => edge.to = to) } - - function compile(expr, from) { - if (expr.type == "choice") { - return expr.exprs.reduce((out, expr) => out.concat(compile(expr, from)), []) - } 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, null, expr.value)] - } - } -} - -function cmp(a, b) { 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, node) { - let result = [] - scan(node) - return result.sort(cmp) - - function scan(node) { - 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) - } - } -} - -// : ([[{term: ?any, to: number}]]) → ContentMatch -// 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) { - let labeled = Object.create(null) - return explore(nullFrom(nfa, 0)) - - function explore(states) { - let out = [] - states.forEach(node => { - nfa[node].forEach(({term, to}) => { - if (!term) return - let known = out.indexOf(term), set = known > -1 && out[known + 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 += 2) { - let states = out[i + 1].sort(cmp) - state.next.push(out[i], labeled[states.join(",")] || explore(states)) - } - return state - } -} - -function checkForDeadEnds(match, stream) { - 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 += 2) { - let node = state.next[j], next = state.next[j + 1] - nodes.push(node.name) - if (dead && !(node.isText || node.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)") - } -} diff --git a/src/content.ts b/src/content.ts new file mode 100644 index 0000000..e5a4255 --- /dev/null +++ b/src/content.ts @@ -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 && 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)") + } +} diff --git a/src/diff.js b/src/diff.ts similarity index 71% rename from src/diff.js rename to src/diff.ts index 4880c86..84165f9 100644 --- a/src/diff.js +++ b/src/diff.ts @@ -1,4 +1,6 @@ -export function findDiffStart(a, b, pos) { +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 @@ -9,7 +11,7 @@ export function findDiffStart(a, b, pos) { if (!childA.sameMarkup(childB)) return pos if (childA.isText && childA.text != childB.text) { - for (let j = 0; childA.text[j] == childB.text[j]; j++) + for (let j = 0; childA.text![j] == childB.text![j]; j++) pos++ return pos } @@ -21,7 +23,7 @@ export function findDiffStart(a, b, pos) { } } -export function findDiffEnd(a, b, posA, posB) { +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} @@ -35,8 +37,8 @@ export function findDiffEnd(a, b, posA, posB) { 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]) { + 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} diff --git a/src/dom.ts b/src/dom.ts new file mode 100644 index 0000000..304cd1c --- /dev/null +++ b/src/dom.ts @@ -0,0 +1 @@ +export type DOMNode = Node diff --git a/src/fragment.js b/src/fragment.js deleted file mode 100644 index bdea44c..0000000 --- a/src/fragment.js +++ /dev/null @@ -1,280 +0,0 @@ -import {findDiffStart, findDiffEnd} from "./diff" - -// ::- 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 { - constructor(content, size) { - this.content = content - // :: number - // The size of the fragment, which is the total of the size of its - // content nodes. - this.size = size || 0 - if (size == null) for (let i = 0; i < content.length; i++) - this.size += content[i].nodeSize - } - - // :: (number, number, (node: Node, start: number, parent: Node, index: number) → ?bool, ?number) - // 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, to, f, nodeStart = 0, parent) { - 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, 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 - } - } - - // :: ((node: Node, pos: number, parent: Node) → ?bool) - // 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) { - this.nodesBetween(0, this.size, f) - } - - // :: (number, number, ?string, ?union) → string - // Extract the text between `from` and `to`. See the same method on - // [`Node`](#model.Node.textBetween). - textBetween(from, to, blockSeparator, leafText) { - let text = "", separated = true - this.nodesBetween(from, to, (node, pos) => { - if (node.isText) { - text += node.text.slice(Math.max(from, pos) - pos, to - pos) - separated = !blockSeparator - } else if (node.isLeaf && leafText) { - text += typeof leafText === 'function' ? leafText(node): leafText - separated = !blockSeparator - } else if (!separated && node.isBlock) { - text += blockSeparator - separated = true - } - }, 0) - return text - } - - // :: (Fragment) → Fragment - // Create a new fragment containing the combined content of this - // fragment and the other. - append(other) { - 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.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) - } - - // :: (number, ?number) → Fragment - // Cut out the sub-fragment between the two given positions. - cut(from, to) { - if (to == null) 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) - } - - cutByIndex(from, to) { - if (from == to) return Fragment.empty - if (from == 0 && to == this.content.length) return this - return new Fragment(this.content.slice(from, to)) - } - - // :: (number, Node) → Fragment - // Create a new fragment in which the node at the given index is - // replaced by the given node. - replaceChild(index, 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) - } - - // : (Node) → Fragment - // Create a new fragment by prepending the given node to this - // fragment. - addToStart(node) { - return new Fragment([node].concat(this.content), this.size + node.nodeSize) - } - - // : (Node) → Fragment - // Create a new fragment by appending the given node to this - // fragment. - addToEnd(node) { - return new Fragment(this.content.concat(node), this.size + node.nodeSize) - } - - // :: (Fragment) → bool - // Compare this fragment to another one. - eq(other) { - 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 - } - - // :: ?Node - // The first child of the fragment, or `null` if it is empty. - get firstChild() { return this.content.length ? this.content[0] : null } - - // :: ?Node - // The last child of the fragment, or `null` if it is empty. - get lastChild() { return this.content.length ? this.content[this.content.length - 1] : null } - - // :: number - // The number of child nodes in this fragment. - get childCount() { return this.content.length } - - // :: (number) → Node - // Get the child node at the given index. Raise an error when the - // index is out of range. - child(index) { - let found = this.content[index] - if (!found) throw new RangeError("Index " + index + " out of range for " + this) - return found - } - - // :: (number) → ?Node - // Get the child node at the given index, if it exists. - maybeChild(index) { - return this.content[index] - } - - // :: ((node: Node, offset: number, index: number)) - // Call `f` for every child node, passing the node, its offset - // into this parent node, and its index. - forEach(f) { - for (let i = 0, p = 0; i < this.content.length; i++) { - let child = this.content[i] - f(child, p, i) - p += child.nodeSize - } - } - - // :: (Fragment) → ?number - // Find the first position at which this fragment and another - // fragment differ, or `null` if they are the same. - findDiffStart(other, pos = 0) { - return findDiffStart(this, other, pos) - } - - // :: (Fragment) → ?{a: number, b: number} - // 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, pos = this.size, otherPos = other.size) { - return findDiffEnd(this, other, pos, otherPos) - } - - // : (number, ?number) → {index: number, offset: number} - // 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, round = -1) { - 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 - } - } - - // :: () → string - // Return a debugging string that describes this fragment. - toString() { return "<" + this.toStringInner() + ">" } - - toStringInner() { return this.content.join(", ") } - - // :: () → ?Object - // Create a JSON-serializeable representation of this fragment. - toJSON() { - return this.content.length ? this.content.map(n => n.toJSON()) : null - } - - // :: (Schema, ?Object) → Fragment - // Deserialize a fragment from its JSON representation. - static fromJSON(schema, value) { - if (!value) return Fragment.empty - if (!Array.isArray(value)) throw new RangeError("Invalid input for Fragment.fromJSON") - return new Fragment(value.map(schema.nodeFromJSON)) - } - - // :: ([Node]) → Fragment - // Build a fragment from an array of nodes. Ensures that adjacent - // text nodes with the same marks are joined together. - static fromArray(array) { - if (!array.length) return Fragment.empty - let joined, 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.withText(joined[joined.length - 1].text + node.text) - } else if (joined) { - joined.push(node) - } - } - return new Fragment(joined || array, size) - } - - // :: (?union) → Fragment - // 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) { - if (!nodes) return Fragment.empty - if (nodes instanceof Fragment) return nodes - if (Array.isArray(nodes)) return this.fromArray(nodes) - if (nodes.attrs) return new Fragment([nodes], nodes.nodeSize) - throw new RangeError("Can not convert " + nodes + " to a Fragment" + - (nodes.nodesBetween ? " (looks like multiple versions of prosemirror-model were loaded)" : "")) - } -} - -const found = {index: 0, offset: 0} -function retIndex(index, offset) { - found.index = index - found.offset = offset - return found -} - -// :: Fragment -// 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). -Fragment.empty = new Fragment([], 0) diff --git a/src/fragment.ts b/src/fragment.ts new file mode 100644 index 0000000..1fdf7c2 --- /dev/null +++ b/src/fragment.ts @@ -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) => 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 = "", separated = true + this.nodesBetween(from, to, (node, pos) => { + if (node.isText) { + text += node.text!.slice(Math.max(from, pos) - pos, to - pos) + separated = !blockSeparator + } else if (node.isLeaf && leafText) { + text += typeof leafText === 'function' ? leafText(node): leafText + separated = !blockSeparator + } else if (!separated && node.isBlock) { + text += blockSeparator + separated = true + } + }, 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 +} diff --git a/src/from_dom.js b/src/from_dom.ts similarity index 57% rename from src/from_dom.js rename to src/from_dom.ts index ffee600..b0568b6 100644 --- a/src/from_dom.js +++ b/src/from_dom.ts @@ -1,168 +1,180 @@ 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) => ParseRule | null + /// @internal + topOpen?: boolean +} -// ParseOptions:: interface -// These are the options recognized by the -// [`parse`](#model.DOMParser.parse) and -// [`parseSlice`](#model.DOMParser.parseSlice) methods. -// -// preserveWhitespace:: ?union -// 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. -// -// findPositions:: ?[{node: dom.Node, offset: number}] -// 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. -// -// from:: ?number -// The child node index to start parsing from. -// -// to:: ?number -// The child node index to stop parsing at. -// -// topNode:: ?Node -// 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. -// -// topMatch:: ?ContentMatch -// Provide the starting content match that content parsed into the -// top node is matched against. -// -// context:: ?ResolvedPos -// A set of additional nodes to count as -// [context](#model.ParseRule.context) when parsing, above the -// given [top node](#model.ParseOptions.topNode). - -// ParseRule:: interface -// A value that describes how to parse a given DOM node or inline -// style as a ProseMirror node or mark. -// -// tag:: ?string -// A CSS selector describing the kind of DOM elements to match. A -// single rule should have _either_ a `tag` or a `style` property. -// -// namespace:: ?string -// The namespace to match. This should be used with `tag`. -// Nodes are only matched when the namespace matches or this property -// is null. -// -// style:: ?string -// A CSS property name to match. When given, this rule matches -// 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. -// -// priority:: ?number -// 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. -// -// consuming:: ?boolean -// 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. -// -// context:: ?string -// 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/"`. -// -// node:: ?string -// The name of the node type to create when this rule matches. Only -// valid for rules with a `tag` property, not for style rules. Each -// rule should have one of 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). -// -// mark:: ?string -// The name of the mark type to wrap the matched content in. -// -// ignore:: ?bool -// When true, ignore content that matches this rule. -// -// closeParent:: ?bool -// When true, finding an element that matches this rule will close -// the current node. -// -// skip:: ?bool -// When true, ignore the node that matches this rule, but do parse -// its content. -// -// attrs:: ?Object -// Attributes for the node or mark created by this rule. When -// `getAttrs` is provided, it takes precedence. -// -// getAttrs:: ?(union) → ?union -// 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. -// -// Called with a DOM Element for `tag` rules, and with a string (the -// style's value) for `style` rules. -// -// contentElement:: ?union -// For `tag` rules that produce non-leaf nodes or marks, by default -// the content of the DOM element is parsed as content of the mark -// or 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. -// -// getContent:: ?(dom.Node, schema: Schema) → Fragment -// 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. -// -// preserveWhitespace:: ?union -// 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. - -// ::- 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 { - // :: (Schema, [ParseRule]) - // Create a parser that targets the given schema, using the given - // parsing rules. - constructor(schema, rules) { - // :: Schema - // The schema into which the parser parses. - this.schema = schema - // :: [ParseRule] - // The set of [parse rules](#model.ParseRule) that the parser - // uses, in order of precedence. - this.rules = rules - this.tags = [] - this.styles = [] +/// A value that describes how to parse a given DOM node or inline +/// style as a ProseMirror node or mark. +export interface ParseRule { + /// A CSS selector describing the kind of DOM elements to match. A + /// single rule should have _either_ a `tag` or a `style` property. + tag?: string + + /// The namespace to match. This should be used with `tag`. + /// Nodes are only matched when the namespace matches or this property + /// is null. + namespace?: string + + /// A CSS property name to match. When given, this rule matches + /// 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 + + /// 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 node type to create when this rule matches. Only + /// valid for rules with a `tag` property, not for style rules. Each + /// rule should have one of 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 + + /// 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 + + /// 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. + /// + /// Called with a DOM Element for `tag` rules, and with a string (the + /// style's value) for `style` rules. + getAttrs?: (node: HTMLElement | string) => Attrs | false | null + + /// For `tag` rules that produce non-leaf nodes or marks, by default + /// the content of the DOM element is parsed as content of the mark + /// or 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 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: ParseRule[] = [] + /// @internal + styles: ParseRule[] = [] + /// @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 (rule.tag) this.tags.push(rule) else if (rule.style) this.styles.push(rule) @@ -170,73 +182,73 @@ export class DOMParser { // 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 + if (!/^(ul|ol)\b/.test(r.tag!) || !r.node) return false let node = schema.nodes[r.node] return node.contentMatch.matchType(node) }) } - // :: (dom.Node, ?ParseOptions) → Node - // Parse a document from the content of a DOM node. - parse(dom, options = {}) { + /// 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, null, options.from, options.to) - return context.finish() - } - - // :: (dom.Node, ?ParseOptions) → Slice - // 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, options = {}) { + 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, null, options.from, options.to) - return Slice.maxOpen(context.finish()) + context.addAll(dom, options.from, options.to) + return Slice.maxOpen(context.finish() as Fragment) } - matchTag(dom, context, after) { + /// @internal + matchTag(dom: DOMNode, context: ParseContext, after?: ParseRule) { 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.namespaceURI == rule.namespace) && + 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) + let result = rule.getAttrs(dom as HTMLElement) if (result === false) continue - rule.attrs = result + rule.attrs = result || undefined } return rule } } } - matchStyle(prop, value, context, after) { + /// @internal + matchStyle(prop: string, value: string, context: ParseContext, after?: ParseRule) { for (let i = after ? this.styles.indexOf(after) + 1 : 0; i < this.styles.length; i++) { - let rule = this.styles[i] - if (rule.style.indexOf(prop) != 0 || + 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. - rule.style.length > prop.length && - (rule.style.charCodeAt(prop.length) != 61 || rule.style.slice(prop.length + 1) != 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 + rule.attrs = result || undefined } return rule } } - // : (Schema) → [ParseRule] - static schemaRules(schema) { - let result = [] - function insert(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 @@ -262,18 +274,16 @@ export class DOMParser { return result } - // :: (Schema) → DOMParser - // 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) { - return schema.cached.domParser || + /// 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))) } } -// : Object The block-level tags in HTML5 -const blockTags = { +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, @@ -281,47 +291,50 @@ const blockTags = { output: true, p: true, pre: true, section: true, table: true, tfoot: true, ul: true } -// : Object The tags that we normally ignore. -const ignoreTags = { +const ignoreTags: {[tagName: string]: boolean} = { head: true, noscript: true, object: true, script: true, style: true, title: true } -// : Object List tags. -const listTags = {ol: true, ul: 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, preserveWhitespace, base) { +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 { - constructor(type, attrs, marks, pendingMarks, solid, match, options) { - this.type = type - this.attrs = attrs - this.solid = solid - this.match = match || (options & OPT_OPEN_LEFT ? null : type.contentMatch) - this.options = options - this.content = [] + 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 - this.marks = marks - // Marks applied to its children - this.activeMarks = Mark.none + readonly marks: readonly Mark[], // Marks that can't apply here, but will be used in children if possible - this.pendingMarks = pendingMarks - // Nested Marks with same type - this.stashMarks = [] + 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) { + 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) + this.match = this.type.contentMatch.matchFragment(fill)! } else { let start = this.type.contentMatch, wrap if (wrap = start.findWrapping(node.type)) { @@ -335,26 +348,27 @@ class NodeContext { return this.match.findWrapping(node.type) } - finish(openEnd) { + 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))) { - if (last.text.length == m[0].length) this.content.pop() - else this.content[this.content.length - 1] = last.withText(last.text.slice(0, last.text.length - m[0].length)) + 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)) + content = content.append(this.match.fillBefore(Fragment.empty, true)!) return this.type ? this.type.create(this.attrs, content, this.marks) : content } - popFromStashMark(mark) { + 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) { + 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)) && @@ -365,7 +379,7 @@ class NodeContext { } } - inlineContext(node) { + 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()) @@ -373,25 +387,28 @@ class NodeContext { } class ParseContext { - // : (DOMParser, Object) - constructor(parser, options, open) { - // : DOMParser The parser we are using. - this.parser = parser - // : Object The options passed to this parse. - this.options = options - this.isOpen = open - let topNode = options.topNode, topContext - let topOptions = wsOptionsFor(null, options.preserveWhitespace, 0) | (open ? OPT_OPEN_LEFT : 0) + 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 (open) + 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] - // : [Mark] The current set of marks - this.open = 0 this.find = options.findPositions this.needsBlock = false } @@ -400,24 +417,23 @@ class ParseContext { return this.nodes[this.open] } - // : (dom.Node) // 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) { + addDOM(dom: DOMNode) { if (dom.nodeType == 3) { - this.addTextNode(dom) + this.addTextNode(dom as Text) } else if (dom.nodeType == 1) { - let style = dom.getAttribute("style") + let style = (dom as HTMLElement).getAttribute("style") let marks = style ? this.readStyles(parseStyles(style)) : null, top = this.top if (marks != null) for (let i = 0; i < marks.length; i++) this.addPendingMark(marks[i]) - this.addElement(dom) + this.addElement(dom as HTMLElement) if (marks != null) for (let i = 0; i < marks.length; i++) this.removePendingMark(marks[i], top) } } - addTextNode(dom) { - let value = dom.nodeValue + addTextNode(dom: Text) { + let value = dom.nodeValue! let top = this.top if (top.options & OPT_PRESERVE_WS_FULL || top.inlineContext(dom) || @@ -432,7 +448,7 @@ class ParseContext { let domNodeBefore = dom.previousSibling if (!nodeBefore || (domNodeBefore && domNodeBefore.nodeName == 'BR') || - (nodeBefore.isText && /[ \t\r\n\u000c]$/.test(nodeBefore.text))) + (nodeBefore.isText && /[ \t\r\n\u000c]$/.test(nodeBefore.text!))) value = value.slice(1) } } else if (!(top.options & OPT_PRESERVE_WS_FULL)) { @@ -447,10 +463,9 @@ class ParseContext { } } - // : (dom.Element, ?ParseRule) // 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, matchAfter) { + addElement(dom: HTMLElement, matchAfter?: ParseRule) { let name = dom.nodeName.toLowerCase(), ruleID if (listTags.hasOwnProperty(name) && this.parser.normalizeLists) normalizeList(dom) let rule = (this.options.ruleFromNode && this.options.ruleFromNode(dom)) || @@ -460,7 +475,7 @@ class ParseContext { 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.nodeType) dom = rule.skip + 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)) { sync = true @@ -473,18 +488,18 @@ class ParseContext { if (sync) this.sync(top) this.needsBlock = oldNeedsBlock } else { - this.addElementByRule(dom, rule, rule.consuming === false ? ruleID : null) + this.addElementByRule(dom, rule, rule.consuming === false ? ruleID : undefined) } } // Called for leaf DOM nodes that would otherwise be ignored - leafFallback(dom) { + leafFallback(dom: DOMNode) { if (dom.nodeName == "BR" && this.top.type && this.top.type.inlineContent) - this.addTextNode(dom.ownerDocument.createTextNode("\n")) + this.addTextNode(dom.ownerDocument!.createTextNode("\n")) } // Called for ignored nodes - ignoreFallback(dom) { + 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("-")) @@ -493,14 +508,14 @@ class ParseContext { // 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) { + readStyles(styles: readonly string[]) { let marks = Mark.none style: for (let i = 0; i < styles.length; i += 2) { - for (let after = null;;) { + for (let after = undefined;;) { let rule = this.parser.matchStyle(styles[i], styles[i + 1], this, after) if (!rule) continue style if (rule.ignore) return null - marks = this.parser.schema.marks[rule.mark].create(rule.attrs).addToSet(marks) + marks = this.parser.schema.marks[rule.mark!].create(rule.attrs).addToSet(marks) if (rule.consuming === false) after = rule else break } @@ -508,21 +523,20 @@ class ParseContext { return marks } - // : (dom.Element, ParseRule) → bool // 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, rule, continueAfter) { - let sync, nodeType, markType, mark + addElementByRule(dom: HTMLElement, rule: ParseRule, continueAfter?: ParseRule) { + let sync, nodeType, mark if (rule.node) { nodeType = this.parser.schema.nodes[rule.node] if (!nodeType.isLeaf) { - sync = this.enter(nodeType, rule.attrs, rule.preserveWhitespace) + sync = this.enter(nodeType, rule.attrs || null, rule.preserveWhitespace) } else if (!this.insertNode(nodeType.create(rule.attrs))) { this.leafFallback(dom) } } else { - markType = this.parser.schema.marks[rule.mark] + let markType = this.parser.schema.marks[rule.mark!] mark = markType.create(rule.attrs) this.addPendingMark(mark) } @@ -536,30 +550,27 @@ class ParseContext { this.findInside(dom) rule.getContent(dom, this.parser.schema).forEach(node => this.insertNode(node)) } else { - let contentDOM = rule.contentElement - if (typeof contentDOM == "string") contentDOM = dom.querySelector(contentDOM) - else if (typeof contentDOM == "function") contentDOM = contentDOM(dom) - if (!contentDOM) contentDOM = dom + 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, sync) + this.addAll(contentDOM) } if (sync) { this.sync(startIn); this.open-- } if (mark) this.removePendingMark(mark, startIn) } - // : (dom.Node, ?NodeBuilder, ?number, ?number) // 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, sync, startIndex, endIndex) { + 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) { + dom != end; dom = dom!.nextSibling, ++index) { this.findAtPoint(parent, index) - this.addDOM(dom) - if (sync && blockTags.hasOwnProperty(dom.nodeName.toLowerCase())) - this.sync(sync) + this.addDOM(dom!) } this.findAtPoint(parent, index) } @@ -567,8 +578,8 @@ class ParseContext { // 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) { - let route, sync + 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) @@ -580,15 +591,14 @@ class ParseContext { if (cx.solid) break } if (!route) return false - this.sync(sync) + this.sync(sync!) for (let i = 0; i < route.length; i++) this.enterInner(route[i], null, false) return true } - // : (Node) → ?Node // Try to insert the given node, adjusting the context when needed. - insertNode(node) { + insertNode(node: Node) { if (node.isInline && this.needsBlock && !this.top.type) { let block = this.textblockFromContext() if (block) this.enterInner(block) @@ -608,21 +618,20 @@ class ParseContext { return false } - // : (NodeType, ?Object) → bool // Try to start a node of the given type, adjusting the context when // necessary. - enter(type, attrs, preserveWS) { + 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, attrs, solid, preserveWS) { + 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, attrs) + 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)) @@ -631,10 +640,10 @@ class ParseContext { // Make sure all nodes above this.open are finished and added to // their parents - closeExtra(openEnd) { + 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)) + for (; i > this.open; i--) this.nodes[i - 1].content.push(this.nodes[i].finish(openEnd) as Node) this.nodes.length = this.open + 1 } } @@ -645,7 +654,7 @@ class ParseContext { return this.nodes[0].finish(this.isOpen || this.options.topOpen) } - sync(to) { + sync(to: NodeContext) { for (let i = this.open; i >= 0; i--) if (this.nodes[i] == to) { this.open = i return @@ -664,21 +673,21 @@ class ParseContext { return pos } - findAtPoint(parent, offset) { + 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) { + 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, content, before) { + 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) @@ -688,17 +697,15 @@ class ParseContext { } } - findInText(textNode) { + 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) + this.find[i].pos = this.currentPos - (textNode.nodeValue!.length - this.find[i].offset) } } - // : (string) → bool - // Determines whether the given [context - // string](#ParseRule.context) matches this context. - matchesContext(context) { + // 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) @@ -706,7 +713,7 @@ class ParseContext { 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, depth) => { + let match = (i: number, depth: number) => { for (; i >= 0; i--) { let part = parts[i] if (part == "") { @@ -740,13 +747,13 @@ class ParseContext { } } - addPendingMark(mark) { + 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, upto) { + removePendingMark(mark: Mark, upto: NodeContext) { for (let depth = this.open; depth >= 0; depth--) { let level = this.nodes[depth] let found = level.pendingMarks.lastIndexOf(mark) @@ -766,7 +773,7 @@ class ParseContext { // 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) { +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) { @@ -781,20 +788,19 @@ function normalizeList(dom) { } // Apply a CSS selector. -function matches(dom, selector) { +function matches(dom: any, selector: string): boolean { return (dom.matches || dom.msMatchesSelector || dom.webkitMatchesSelector || dom.mozMatchesSelector).call(dom, selector) } -// : (string) → [string] // Tokenize a style attribute into property/value pairs. -function parseStyles(style) { +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) { - let copy = {} +function copy(obj: {[prop: string]: any}) { + let copy: {[prop: string]: any} = {} for (let prop in obj) copy[prop] = obj[prop] return copy } @@ -802,12 +808,12 @@ function copy(obj) { // 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, nodeType) { +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 = [], scan = match => { + let seen: ContentMatch[] = [], scan = (match: ContentMatch) => { seen.push(match) for (let i = 0; i < match.edgeCount; i++) { let {type, next} = match.edge(i) @@ -819,7 +825,7 @@ function markMayApply(markType, nodeType) { } } -function findSameMarkInSet(mark, set) { +function findSameMarkInSet(mark: Mark, set: readonly Mark[]) { for (let i = 0; i < set.length; i++) { if (mark.eq(set[i])) return set[i] } diff --git a/src/index.js b/src/index.ts similarity index 51% rename from src/index.js rename to src/index.ts index 495ef38..4c449de 100644 --- a/src/index.js +++ b/src/index.ts @@ -4,8 +4,8 @@ export {Fragment} from "./fragment" export {Slice, ReplaceError} from "./replace" export {Mark} from "./mark" -export {Schema, NodeType, MarkType} from "./schema" +export {Schema, NodeType, Attrs, MarkType, NodeSpec, MarkSpec, AttributeSpec, SchemaSpec} from "./schema" export {ContentMatch} from "./content" -export {DOMParser} from "./from_dom" -export {DOMSerializer} from "./to_dom" +export {DOMParser, ParseRule, ParseOptions} from "./from_dom" +export {DOMSerializer, DOMOutputSpec} from "./to_dom" diff --git a/src/mark.js b/src/mark.js deleted file mode 100644 index b45409e..0000000 --- a/src/mark.js +++ /dev/null @@ -1,116 +0,0 @@ -import {compareDeep} from "./comparedeep" - -// ::- 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 { - constructor(type, attrs) { - // :: MarkType - // The type of this mark. - this.type = type - // :: Object - // The attributes associated with this mark. - this.attrs = attrs - } - - // :: ([Mark]) → [Mark] - // 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) { - 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 - } - - // :: ([Mark]) → [Mark] - // 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) { - 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 - } - - // :: ([Mark]) → bool - // Test whether this mark is in the given set of marks. - isInSet(set) { - for (let i = 0; i < set.length; i++) - if (this.eq(set[i])) return true - return false - } - - // :: (Mark) → bool - // Test whether this mark has the same type and attributes as - // another mark. - eq(other) { - return this == other || - (this.type == other.type && compareDeep(this.attrs, other.attrs)) - } - - // :: () → Object - // Convert this mark to a JSON-serializeable representation. - toJSON() { - let obj = {type: this.type.name} - for (let _ in this.attrs) { - obj.attrs = this.attrs - break - } - return obj - } - - // :: (Schema, Object) → Mark - static fromJSON(schema, json) { - 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) - } - - // :: ([Mark], [Mark]) → bool - // Test whether two sets of marks are identical. - static sameSet(a, b) { - 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 - } - - // :: (?union) → [Mark] - // Create a properly sorted mark set from null, a single mark, or an - // unsorted array of marks. - static setFrom(marks) { - if (!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 - } -} - -// :: [Mark] The empty set of marks. -Mark.none = [] diff --git a/src/mark.ts b/src/mark.ts new file mode 100644 index 0000000..82d66ad --- /dev/null +++ b/src/mark.ts @@ -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[] = [] +} diff --git a/src/node.js b/src/node.js deleted file mode 100644 index d3bc913..0000000 --- a/src/node.js +++ /dev/null @@ -1,419 +0,0 @@ -import {Fragment} from "./fragment" -import {Mark} from "./mark" -import {Slice, replace} from "./replace" -import {ResolvedPos} from "./resolvedpos" -import {compareDeep} from "./comparedeep" - -const emptyAttrs = 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 { - constructor(type, attrs, content, marks) { - // :: NodeType - // The type of node that this is. - this.type = type - - // :: Object - // An object mapping attribute names to values. The kind of - // attributes allowed and required are - // [determined](#model.NodeSpec.attrs) by the node type. - this.attrs = attrs - - // :: Fragment - // A container holding the node's children. - this.content = content || Fragment.empty - - // :: [Mark] - // The marks (things like whether it is emphasized or part of a - // link) applied to this node. - this.marks = marks || Mark.none - } - - // text:: ?string - // For text nodes, this contains the node's text content. - - // :: number - // 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() { return this.isLeaf ? 1 : 2 + this.content.size } - - // :: number - // The number of children that the node has. - get childCount() { return this.content.childCount } - - // :: (number) → Node - // Get the child node at the given index. Raises an error when the - // index is out of range. - child(index) { return this.content.child(index) } - - // :: (number) → ?Node - // Get the child node at the given index, if it exists. - maybeChild(index) { return this.content.maybeChild(index) } - - // :: ((node: Node, offset: number, index: number)) - // Call `f` for every child node, passing the node, its offset - // into this parent node, and its index. - forEach(f) { this.content.forEach(f) } - - // :: (number, number, (node: Node, pos: number, parent: Node, index: number) → ?bool, ?number) - // 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 - // parent-relative position, 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, to, f, startPos = 0) { - this.content.nodesBetween(from, to, f, startPos, this) - } - - // :: ((node: Node, pos: number, parent: Node, index: number) → ?bool) - // Call the given callback for every descendant node. Doesn't - // descend into a node when the callback returns `false`. - descendants(f) { - this.nodesBetween(0, this.content.size, f) - } - - // :: string - // Concatenates all the text nodes found in this fragment and its - // children. - get textContent() { return this.textBetween(0, this.content.size, "") } - - // :: (number, number, ?string, ?union string>) → string - // Get all text between positions `from` and `to`. When - // `blockSeparator` is given, it will be inserted to separate text - // from different block nodes. When `leafText` is given, it'll be - // inserted for every non-text leaf node encountered. - textBetween(from, to, blockSeparator, leafText) { - return this.content.textBetween(from, to, blockSeparator, leafText) - } - - // :: ?Node - // Returns this node's first child, or `null` if there are no - // children. - get firstChild() { return this.content.firstChild } - - // :: ?Node - // Returns this node's last child, or `null` if there are no - // children. - get lastChild() { return this.content.lastChild } - - // :: (Node) → bool - // Test whether two nodes represent the same piece of document. - eq(other) { - return this == other || (this.sameMarkup(other) && this.content.eq(other.content)) - } - - // :: (Node) → bool - // Compare the markup (type, attributes, and marks) of this node to - // those of another. Returns `true` if both have the same markup. - sameMarkup(other) { - return this.hasMarkup(other.type, other.attrs, other.marks) - } - - // :: (NodeType, ?Object, ?[Mark]) → bool - // Check whether this node's markup correspond to the given type, - // attributes, and marks. - hasMarkup(type, attrs, marks) { - return this.type == type && - compareDeep(this.attrs, attrs || type.defaultAttrs || emptyAttrs) && - Mark.sameSet(this.marks, marks || Mark.none) - } - - // :: (?Fragment) → Node - // Create a new node with the same markup as this node, containing - // the given content (or empty, if no content is given). - copy(content = null) { - if (content == this.content) return this - return new this.constructor(this.type, this.attrs, content, this.marks) - } - - // :: ([Mark]) → Node - // Create a copy of this node, with the given set of marks instead - // of the node's own marks. - mark(marks) { - return marks == this.marks ? this : new this.constructor(this.type, this.attrs, this.content, marks) - } - - // :: (number, ?number) → Node - // 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, to) { - if (from == 0 && to == this.content.size) return this - return this.copy(this.content.cut(from, to)) - } - - // :: (number, ?number) → Slice - // Cut out the part of the document between the given positions, and - // return it as a `Slice` object. - slice(from, to = 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) - } - - // :: (number, number, Slice) → Node - // 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, to, slice) { - return replace(this.resolve(from), this.resolve(to), slice) - } - - // :: (number) → ?Node - // Find the node directly after the given position. - nodeAt(pos) { - for (let node = 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 - } - } - - // :: (number) → {node: ?Node, index: number, offset: number} - // 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) { - let {index, offset} = this.content.findIndex(pos) - return {node: this.content.maybeChild(index), index, offset} - } - - // :: (number) → {node: ?Node, index: number, offset: number} - // 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) { - 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} - } - - // :: (number) → ResolvedPos - // Resolve the given position in the document, returning an - // [object](#model.ResolvedPos) with information about its context. - resolve(pos) { return ResolvedPos.resolveCached(this, pos) } - - resolveNoCache(pos) { return ResolvedPos.resolve(this, pos) } - - // :: (number, number, union) → bool - // Test whether a given mark or mark type occurs in this document - // between the two given positions. - rangeHasMark(from, to, type) { - let found = false - if (to > from) this.nodesBetween(from, to, node => { - if (type.isInSet(node.marks)) found = true - return !found - }) - return found - } - - // :: bool - // True when this is a block (non-inline node) - get isBlock() { return this.type.isBlock } - - // :: bool - // True when this is a textblock node, a block node with inline - // content. - get isTextblock() { return this.type.isTextblock } - - // :: bool - // True when this node allows inline content. - get inlineContent() { return this.type.inlineContent } - - // :: bool - // True when this is an inline node (a text node or a node that can - // appear among text). - get isInline() { return this.type.isInline } - - // :: bool - // True when this is a text node. - get isText() { return this.type.isText } - - // :: bool - // True when this is a leaf node. - get isLeaf() { return this.type.isLeaf } - - // :: bool - // 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 } - - // :: () → string - // Return a string representation of this node for debugging - // purposes. - toString() { - 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) - } - - // :: (number) → ContentMatch - // Get the content match in this node at the given index. - contentMatchAt(index) { - 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 - } - - // :: (number, number, ?Fragment, ?number, ?number) → bool - // 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, to, 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 - } - - // :: (number, number, NodeType, ?[Mark]) → bool - // 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, to, type, marks) { - 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 - } - - // :: (Node) → bool - // 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) { - 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() { - if (!this.type.validContent(this.content)) - throw new RangeError(`Invalid content for node ${this.type.name}: ${this.content.toString().slice(0, 50)}`) - 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()) - } - - // :: () → Object - // Return a JSON-serializeable representation of this node. - toJSON() { - let obj = {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 - } - - // :: (Schema, Object) → Node - // Deserialize a node from its JSON representation. - static fromJSON(schema, json) { - 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) - } -} - -export class TextNode extends Node { - constructor(type, attrs, content, marks) { - 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, to) { return this.text.slice(from, to) } - - get nodeSize() { return this.text.length } - - mark(marks) { - return marks == this.marks ? this : new TextNode(this.type, this.attrs, this.text, marks) - } - - withText(text) { - 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) { - return this.sameMarkup(other) && this.text == other.text - } - - toJSON() { - let base = super.toJSON() - base.text = this.text - return base - } -} - -function wrapMarks(marks, str) { - for (let i = marks.length - 1; i >= 0; i--) - str = marks[i].type.name + "(" + str + ")" - return str -} diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 0000000..845c359 --- /dev/null +++ b/src/node.ts @@ -0,0 +1,387 @@ +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 + /// parent-relative position, 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.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. When `leafText` is given, it'll be + /// inserted for every non-text leaf node encountered. + 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() { + if (!this.type.validContent(this.content)) + throw new RangeError(`Invalid content for node ${this.type.name}: ${this.content.toString().slice(0, 50)}`) + 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 +} diff --git a/src/replace.js b/src/replace.ts similarity index 60% rename from src/replace.js rename to src/replace.ts index b214a48..519116f 100644 --- a/src/replace.js +++ b/src/replace.ts @@ -1,80 +1,83 @@ import {Fragment} from "./fragment" - -// ReplaceError:: class extends Error -// Error type raised by [`Node.replace`](#model.Node.replace) when -// given an invalid replacement. - -export function ReplaceError(message) { +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.__proto__ = ReplaceError.prototype + ;(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). +/// 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 { - // :: (Fragment, number, number) - // 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(content, openStart, openEnd) { - // :: Fragment The slice's content. - this.content = content - // :: number The open depth at the start. - this.openStart = openStart - // :: number The open depth at the end. - this.openEnd = openEnd - } - - // :: number - // The size this slice would add when inserted into a document. - get size() { + /// 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 } - insertAt(pos, fragment) { - let content = insertInto(this.content, pos + this.openStart, fragment, null) + /// @internal + insertAt(pos: number, fragment: Fragment) { + let content = insertInto(this.content, pos + this.openStart, fragment) return content && new Slice(content, this.openStart, this.openEnd) } - removeBetween(from, to) { + /// @internal + removeBetween(from: number, to: number) { return new Slice(removeRange(this.content, from + this.openStart, to + this.openStart), this.openStart, this.openEnd) } - // :: (Slice) → bool - // Tests whether this slice is equal to another slice. - eq(other) { + /// 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 + ")" } - // :: () → ?Object - // Convert a slice to a JSON-serializable representation. - toJSON() { + /// Convert a slice to a JSON-serializable representation. + toJSON(): any { if (!this.content.size) return null - let json = {content: this.content.toJSON()} + 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 } - // :: (Schema, ?Object) → Slice - // Deserialize a slice from its JSON representation. - static fromJSON(schema, 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") @@ -82,43 +85,41 @@ export class Slice { return new Slice(Fragment.fromJSON(schema, json.content), openStart, openEnd) } - // :: (Fragment, ?bool) → Slice - // Create a slice from a fragment by taking the maximum possible - // open value on both side of the fragment. - static maxOpen(fragment, openIsolating=true) { + /// 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, from, to) { +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 (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))) + return content.replaceChild(index, child!.copy(removeRange(child!.content, from - offset - 1, to - offset - 1))) } -function insertInto(content, dist, insert, parent) { +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 (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)) + let inner = insertInto(child!.content, dist - offset - 1, insert) + return inner && content.replaceChild(index, child!.copy(inner)) } -// :: Slice -// The empty slice. -Slice.empty = new Slice(Fragment.empty, 0, 0) - -export function replace($from, $to, slice) { +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) @@ -126,7 +127,7 @@ export function replace($from, $to, slice) { return replaceOuter($from, $to, slice, 0) } -function replaceOuter($from, $to, slice, depth) { +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) @@ -142,53 +143,53 @@ function replaceOuter($from, $to, slice, depth) { } } -function checkJoin(main, sub) { +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, $after, depth) { +function joinable($before: ResolvedPos, $after: ResolvedPos, depth: number) { let node = $before.node(depth) checkJoin(node, $after.node(depth)) return node } -function addNode(child, target) { +function addNode(child: Node, target: Node[]) { let last = target.length - 1 if (last >= 0 && child.isText && child.sameMarkup(target[last])) - target[last] = child.withText(target[last].text + child.text) + target[last] = (child as TextNode).withText(target[last].text! + child.text!) else target.push(child) } -function addRange($start, $end, depth, target) { - let node = ($end || $start).node(depth) +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) + 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) + addNode($end.nodeBefore!, target) } -function close(node, content) { +function close(node: Node, content: Fragment) { if (!node.type.validContent(content)) throw new ReplaceError("Invalid content for node " + node.type.name) return node.copy(content) } -function replaceThreeWay($from, $start, $end, $to, depth) { +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 = [] + let content: Node[] = [] addRange(null, $from, depth, content) if (openStart && openEnd && $start.index(depth) == $end.index(depth)) { checkJoin(openStart, openEnd) @@ -204,8 +205,8 @@ function replaceThreeWay($from, $start, $end, $to, depth) { return new Fragment(content) } -function replaceTwoWay($from, $to, depth) { - let 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) @@ -215,7 +216,7 @@ function replaceTwoWay($from, $to, depth) { return new Fragment(content) } -function prepareSliceForReplace(slice, $along) { +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--) diff --git a/src/resolvedpos.js b/src/resolvedpos.js deleted file mode 100644 index 6ca179b..0000000 --- a/src/resolvedpos.js +++ /dev/null @@ -1,291 +0,0 @@ -import {Mark} from "./mark" - -// ::- 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 { - constructor(pos, path, parentOffset) { - // :: number The position that was resolved. - this.pos = pos - this.path = path - // :: number - // 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. - this.depth = path.length / 3 - 1 - // :: number The offset this position has into its parent node. - this.parentOffset = parentOffset - } - - resolveDepth(val) { - if (val == null) return this.depth - if (val < 0) return this.depth + val - return val - } - - // :: Node - // 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) } - - // :: Node - // The root node in which the position was resolved. - get doc() { return this.node(0) } - - // :: (?number) → Node - // The ancestor node at the given level. `p.node(p.depth)` is the - // same as `p.parent`. - node(depth) { return this.path[this.resolveDepth(depth) * 3] } - - // :: (?number) → number - // 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) { return this.path[this.resolveDepth(depth) * 3 + 1] } - - // :: (?number) → number - // The index pointing after this position into the ancestor at the - // given level. - indexAfter(depth) { - depth = this.resolveDepth(depth) - return this.index(depth) + (depth == this.depth && !this.textOffset ? 0 : 1) - } - - // :: (?number) → number - // The (absolute) position at the start of the node at the given - // level. - start(depth) { - depth = this.resolveDepth(depth) - return depth == 0 ? 0 : this.path[depth * 3 - 1] + 1 - } - - // :: (?number) → number - // The (absolute) position at the end of the node at the given - // level. - end(depth) { - depth = this.resolveDepth(depth) - return this.start(depth) + this.node(depth).content.size - } - - // :: (?number) → number - // The (absolute) position directly before the wrapping node at the - // given level, or, when `depth` is `this.depth + 1`, the original - // position. - before(depth) { - 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] - } - - // :: (?number) → number - // The (absolute) position directly after the wrapping node at the - // given level, or the original position when `depth` is `this.depth + 1`. - after(depth) { - 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 - } - - // :: number - // 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() { return this.pos - this.path[this.path.length - 1] } - - // :: ?Node - // 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() { - 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 - } - - // :: ?Node - // 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() { - 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) - } - - // :: (number, ?number) → number - // Get the position at the given index in the parent node at the - // given depth (which defaults to `this.depth`). - posAtIndex(index, depth) { - 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 - } - - // :: () → [Mark] - // 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() { - 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 - } - - // :: (ResolvedPos) → ?[Mark] - // 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) { - 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 - } - - // :: (number) → number - // The depth up to which this position and the given (non-resolved) - // position share the same parent nodes. - sharedDepth(pos) { - for (let depth = this.depth; depth > 0; depth--) - if (this.start(depth) <= pos && this.end(depth) >= pos) return depth - return 0 - } - - // :: (?ResolvedPos, ?(Node) → bool) → ?NodeRange - // 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 = this, pred) { - 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) - } - - // :: (ResolvedPos) → bool - // Query whether the given position shares the same parent node. - sameParent(other) { - return this.pos - this.parentOffset == other.pos - other.parentOffset - } - - // :: (ResolvedPos) → ResolvedPos - // Return the greater of this and the given position. - max(other) { - return other.pos > this.pos ? other : this - } - - // :: (ResolvedPos) → ResolvedPos - // Return the smaller of this and the given position. - min(other) { - return other.pos < this.pos ? other : this - } - - 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 - } - - static resolve(doc, pos) { - 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) - } - - static resolveCached(doc, pos) { - 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 = [], resolveCachePos = 0, resolveCacheSize = 12 - -// ::- Represents a flat range of content, i.e. one that starts and -// ends in the same node. -export class NodeRange { - // :: (ResolvedPos, ResolvedPos, number) - // 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($from, $to, depth) { - // :: ResolvedPos 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. - this.$from = $from - // :: ResolvedPos A position along the end of the content. See - // caveat for [`$from`](#model.NodeRange.$from). - this.$to = $to - // :: number The depth of the node that this range points into. - this.depth = depth - } - - // :: number The position at the start of the range. - get start() { return this.$from.before(this.depth + 1) } - // :: number The position at the end of the range. - get end() { return this.$to.after(this.depth + 1) } - - // :: Node The parent node that the range points into. - get parent() { return this.$from.node(this.depth) } - // :: number The start index of the range in the parent node. - get startIndex() { return this.$from.index(this.depth) } - // :: number The end index of the range in the parent node. - get endIndex() { return this.$to.indexAfter(this.depth) } -} diff --git a/src/resolvedpos.ts b/src/resolvedpos.ts new file mode 100644 index 0000000..93635db --- /dev/null +++ b/src/resolvedpos.ts @@ -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) } +} diff --git a/src/schema.js b/src/schema.js deleted file mode 100644 index c375fca..0000000 --- a/src/schema.js +++ /dev/null @@ -1,609 +0,0 @@ -import OrderedMap from "orderedmap" - -import {Node, TextNode} from "./node" -import {Fragment} from "./fragment" -import {Mark} from "./mark" -import {ContentMatch} from "./content" - -// 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) { - 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, value) { - 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) { - let result = 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 { - constructor(name, schema, spec) { - // :: string - // The name the node type has in this schema. - this.name = name - - // :: Schema - // A link back to the `Schema` the node type belongs to. - this.schema = schema - - // :: NodeSpec - // The spec that this type is based on - this.spec = spec - - this.groups = spec.group ? spec.group.split(" ") : [] - this.attrs = initAttrs(spec.attrs) - - this.defaultAttrs = defaultAttrs(this.attrs) - - // :: ContentMatch - // The starting match of the node type's content expression. - this.contentMatch = null - - // : ?[MarkType] - // The set of marks allowed in this node. `null` means all marks - // are allowed. - this.markSet = null - - // :: bool - // True if this node type has inline content. - this.inlineContent = null - - // :: bool - // True if this is a block type - this.isBlock = !(spec.inline || name == "text") - - // :: bool - // True if this is the text node type. - this.isText = name == "text" - } - - // :: bool - // True if this is an inline type. - get isInline() { return !this.isBlock } - - // :: bool - // True if this is a textblock type, a block that contains inline - // content. - get isTextblock() { return this.isBlock && this.inlineContent } - - // :: bool - // True for node types that allow no content. - get isLeaf() { return this.contentMatch == ContentMatch.empty } - - // :: bool - // 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 } - - // :: union<"pre", "normal"> - // The node type's [whitespace](#view.NodeSpec.whitespace) option. - get whitespace() { return this.spec.whitespace || (this.spec.code ? "pre" : "normal") } - - // :: () → bool - // 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 - } - - compatibleContent(other) { - return this == other || this.contentMatch.compatible(other.contentMatch) - } - - computeAttrs(attrs) { - if (!attrs && this.defaultAttrs) return this.defaultAttrs - else return computeAttrs(this.attrs, attrs) - } - - // :: (?Object, ?union, ?[Mark]) → Node - // 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, content, marks) { - 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)) - } - - // :: (?Object, ?union, ?[Mark]) → Node - // 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, content, marks) { - content = Fragment.from(content) - if (!this.validContent(content)) - throw new RangeError("Invalid content for node " + this.name) - return new Node(this, this.computeAttrs(attrs), content, Mark.setFrom(marks)) - } - - // :: (?Object, ?union, ?[Mark]) → ?Node - // 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, content, marks) { - 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 after = this.contentMatch.matchFragment(content).fillBefore(Fragment.empty, true) - if (!after) return null - return new Node(this, attrs, content.append(after), Mark.setFrom(marks)) - } - - // :: (Fragment) → bool - // Returns true if the given fragment is valid content for this node - // type with the given attributes. - validContent(content) { - 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 - } - - // :: (MarkType) → bool - // Check whether the given mark type is allowed in this node. - allowsMarkType(markType) { - return this.markSet == null || this.markSet.indexOf(markType) > -1 - } - - // :: ([Mark]) → bool - // Test whether the given set of marks are allowed in this node. - allowsMarks(marks) { - if (this.markSet == null) return true - for (let i = 0; i < marks.length; i++) if (!this.allowsMarkType(marks[i].type)) return false - return true - } - - // :: ([Mark]) → [Mark] - // Removes the marks that are not allowed in this node from the given set. - allowedMarks(marks) { - 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.empty - } - - static compile(nodes, schema) { - 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 { - constructor(options) { - 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 { - constructor(name, rank, schema, spec) { - // :: string - // The name of the mark type. - this.name = name - - // :: Schema - // The schema that this mark type instance is part of. - this.schema = schema - - // :: MarkSpec - // The spec on which the type is based. - this.spec = spec - - this.attrs = initAttrs(spec.attrs) - - this.rank = rank - this.excluded = null - let defaults = defaultAttrs(this.attrs) - this.instance = defaults && new Mark(this, defaults) - } - - // :: (?Object) → Mark - // 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) { - if (!attrs && this.instance) return this.instance - return new Mark(this, computeAttrs(this.attrs, attrs)) - } - - static compile(marks, schema) { - let result = Object.create(null), rank = 0 - marks.forEach((name, spec) => result[name] = new MarkType(name, rank++, schema, spec)) - return result - } - - // :: ([Mark]) → [Mark] - // 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) { - 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 - } - - // :: ([Mark]) → ?Mark - // Tests whether there is a mark of this type in the given set. - isInSet(set) { - for (let i = 0; i < set.length; i++) - if (set[i].type == this) return set[i] - } - - // :: (MarkType) → bool - // Queries whether a given mark type is - // [excluded](#model.MarkSpec.excludes) by this one. - excludes(other) { - return this.excluded.indexOf(other) > -1 - } -} - -// SchemaSpec:: interface -// An object describing a schema, as passed to the [`Schema`](#model.Schema) -// constructor. -// -// nodes:: union, OrderedMap> -// 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). -// -// marks:: ?union, OrderedMap> -// 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. -// -// topNode:: ?string -// The name of the default top-level node for the schema. Defaults -// to `"doc"`. - -// NodeSpec:: interface -// -// content:: ?string -// 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. -// -// marks:: ?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. -// -// group:: ?string -// The group or space-separated groups to which this node belongs, -// which can be referred to in the content expressions for the -// schema. -// -// inline:: ?bool -// Should be set to true for inline nodes. (Implied for text nodes.) -// -// atom:: ?bool -// 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. -// -// attrs:: ?Object -// The attributes that nodes of this type get. -// -// selectable:: ?bool -// Controls whether nodes of this type can be selected as a [node -// selection](#state.NodeSelection). Defaults to true for non-text -// nodes. -// -// draggable:: ?bool -// Determines whether nodes of this type can be dragged without -// being selected. Defaults to false. -// -// code:: ?bool -// Can be used to indicate that this node contains code, which -// causes some commands to behave differently. -// -// whitespace:: ?union<"pre", "normal"> -// 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. -// -// definingAsContext:: ?bool -// 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. -// -// definingForContent:: ?bool -// 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. -// -// defining:: ?bool -// When enabled, enables both -// [`definingAsContext`](#model.NodeSpec.definingAsContext) and -// [`definingForContent`](#model.NodeSpec.definingForContent). -// -// isolating:: ?bool -// 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. -// -// toDOM:: ?(node: Node) → DOMOutputSpec -// 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. -// -// parseDOM:: ?[ParseRule] -// 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. -// -// toDebugString:: ?(node: Node) -> string -// Defines the default way a node of this type should be serialized -// to a string representation for debugging (e.g. in error messages). - -// MarkSpec:: interface -// -// attrs:: ?Object -// The attributes that marks of this type get. -// -// inclusive:: ?bool -// 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. -// -// excludes:: ?string -// 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). -// -// group:: ?string -// The group or space-separated groups to which this mark belongs. -// -// spanning:: ?bool -// Determines whether marks of this type can span multiple adjacent -// nodes when serialized to DOM/HTML. Defaults to true. -// -// toDOM:: ?(mark: Mark, inline: bool) → DOMOutputSpec -// 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. -// -// parseDOM:: ?[ParseRule] -// Associates DOM parser information with this mark (see the -// corresponding [node spec field](#model.NodeSpec.parseDOM)). The -// `mark` field in the rules is implied. - -// AttributeSpec:: interface -// -// Used to [define](#model.NodeSpec.attrs) attributes on nodes or -// marks. -// -// default:: ?any -// 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. - -// ::- 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. -export class Schema { - // :: (SchemaSpec) - // Construct a schema from a schema [specification](#model.SchemaSpec). - constructor(spec) { - // :: SchemaSpec - // 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). - this.spec = {} - for (let prop in spec) this.spec[prop] = spec[prop] - this.spec.nodes = OrderedMap.from(spec.nodes) - this.spec.marks = OrderedMap.from(spec.marks) - - // :: Object - // An object mapping the schema's node names to node type objects. - this.nodes = NodeType.compile(this.spec.nodes, this) - - // :: Object - // A map from mark names to mark type objects. - 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.inlineContent = type.contentMatch.inlineContent - 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) - - // :: NodeType - // The type of the [default top node](#model.SchemaSpec.topNode) - // for this schema. - this.topNodeType = this.nodes[this.spec.topNode || "doc"] - - // :: Object - // 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.) - this.cached = Object.create(null) - this.cached.wrappings = Object.create(null) - } - - // :: (union, ?Object, ?union, ?[Mark]) → Node - // 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, attrs, content, marks) { - 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) - } - - // :: (string, ?[Mark]) → Node - // Create a text node in the schema. Empty text nodes are not - // allowed. - text(text, marks) { - let type = this.nodes.text - return new TextNode(type, type.defaultAttrs, text, Mark.setFrom(marks)) - } - - // :: (union, ?Object) → Mark - // Create a mark with the given type and attributes. - mark(type, attrs) { - if (typeof type == "string") type = this.marks[type] - return type.create(attrs) - } - - // :: (Object) → Node - // Deserialize a node from its JSON representation. This method is - // bound. - nodeFromJSON(json) { - return Node.fromJSON(this, json) - } - - // :: (Object) → Mark - // Deserialize a mark from its JSON representation. This method is - // bound. - markFromJSON(json) { - return Mark.fromJSON(this, json) - } - - nodeType(name) { - let found = this.nodes[name] - if (!found) throw new RangeError("Unknown node type: " + name) - return found - } -} - -function gatherMarks(schema, marks) { - 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 -} diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..2e087f9 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,626 @@ +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} 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 + } + + /// @internal + 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) + if (!this.validContent(content)) + throw new RangeError("Invalid content for node " + this.name) + 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 + } + + /// 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: OrderedMap, schema: Schema): {readonly [name: string]: 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, 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 { + /// 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: string]: NodeSpec} | OrderedMap, + + /// 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: string]: MarkSpec} | OrderedMap + + /// 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 ParseRule[] + + /// 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 + + /// 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. +export class Schema { + /// 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, + marks: OrderedMap, + topNode?: string + } + + /// An object mapping the schema's node names to node type objects. + nodes: {readonly [name: string]: NodeType} + + /// A map from mark names to mark type objects. + marks: {readonly [name: string]: MarkType} + + /// Construct a schema from a schema [specification](#model.SchemaSpec). + constructor(spec: SchemaSpec) { + this.spec = { + nodes: OrderedMap.from(spec.nodes), + marks: OrderedMap.from(spec.marks || {}), + topNode: spec.topNode + } + + 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 + 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) { + return Node.fromJSON(this, json) + } + + /// Deserialize a mark from its JSON representation. This method is + /// bound. + markFromJSON(json: any) { + 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 +} diff --git a/src/to_dom.js b/src/to_dom.js deleted file mode 100644 index 4e617f0..0000000 --- a/src/to_dom.js +++ /dev/null @@ -1,195 +0,0 @@ -// DOMOutputSpec:: interface -// 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: Node, contentDOM: ?Node}` 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. - -// ::- A DOM serializer knows how to convert ProseMirror nodes and -// marks of various types to DOM nodes. -export class DOMSerializer { - // :: (Object<(node: Node) → DOMOutputSpec>, Object) - // 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(nodes, marks) { - // :: Object<(node: Node) → DOMOutputSpec> - // The node serialization functions. - this.nodes = nodes || {} - // :: Object - // The mark serialization functions. - this.marks = marks || {} - } - - // :: (Fragment, ?Object) → dom.DocumentFragment - // 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, options = {}, target) { - if (!target) target = doc(options).createDocumentFragment() - - let top = target, active = null - fragment.forEach(node => { - if (active || node.marks.length) { - if (!active) active = [] - 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]) || next.type.spec.spanning === false) break - keep += 2; rendered++ - } - while (keep < active.length) { - top = active.pop() - active.pop() - } - 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 - } - } - } - top.appendChild(this.serializeNodeInner(node, options)) - }) - - return target - } - - serializeNodeInner(node, options = {}) { - 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") - if (options.onContent) - options.onContent(node, contentDOM, options) - else - this.serializeFragment(node.content, options, contentDOM) - } - return dom - } - - // :: (Node, ?Object) → dom.Node - // 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, options = {}) { - 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 - } - - serializeMark(mark, inline, options = {}) { - let toDOM = this.marks[mark.type.name] - return toDOM && DOMSerializer.renderSpec(doc(options), toDOM(mark, inline)) - } - - // :: (dom.Document, DOMOutputSpec) → {dom: dom.Node, contentDOM: ?dom.Node} - // 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, structure, xmlNS = null) { - if (typeof structure == "string") - return {dom: doc.createTextNode(structure)} - if (structure.nodeType != null) - return {dom: structure} - if (structure.dom && structure.dom.nodeType != null) - return structure - let tagName = structure[0], space = tagName.indexOf(" ") - if (space > 0) { - xmlNS = tagName.slice(0, space) - tagName = tagName.slice(space + 1) - } - let contentDOM = null, dom = xmlNS ? doc.createElementNS(xmlNS, tagName) : doc.createElement(tagName) - let attrs = structure[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.length; i++) { - let child = structure[i] - if (child === 0) { - if (i < structure.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 - } - } - } - return {dom, contentDOM} - } - - // :: (Schema) → DOMSerializer - // Build a serializer using the [`toDOM`](#model.NodeSpec.toDOM) - // properties in a schema's node and mark specs. - static fromSchema(schema) { - return schema.cached.domSerializer || - (schema.cached.domSerializer = new DOMSerializer(this.nodesFromSchema(schema), this.marksFromSchema(schema))) - } - - // : (Schema) → Object<(node: Node) → DOMOutputSpec> - // 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) { - let result = gatherToDOM(schema.nodes) - if (!result.text) result.text = node => node.text - return result - } - - // : (Schema) → Object<(mark: Mark) → DOMOutputSpec> - // Gather the serializers in a schema's mark specs into an object. - static marksFromSchema(schema) { - return gatherToDOM(schema.marks) - } -} - -function gatherToDOM(obj) { - let result = {} - for (let name in obj) { - let toDOM = obj[name].spec.toDOM - if (toDOM) result[name] = toDOM - } - return result -} - -function doc(options) { - // declare global: window - return options.document || window.document -} diff --git a/src/to_dom.ts b/src/to_dom.ts new file mode 100644 index 0000000..a386888 --- /dev/null +++ b/src/to_dom.ts @@ -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} | [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 any[]).length; i++) { + let child = (structure as any)[i] as DOMOutputSpec | 0 + if (child === 0) { + if (i < (structure as 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 +} diff --git a/test/test-content.js b/test/test-content.ts similarity index 83% rename from test/test-content.js rename to test/test-content.ts index e54ee06..8d030a9 100644 --- a/test/test-content.js +++ b/test/test-content.ts @@ -1,31 +1,31 @@ -const {ContentMatch} = require("..") -const {schema, eq, doc, p, pre, img, br, h1, hr} = require("prosemirror-test-builder") -const ist = require("ist") +import {ContentMatch, Node} from "prosemirror-model" +import {schema, eq, doc, p, pre, img, br, h1, hr} from "prosemirror-test-builder" +import ist from "ist" -function get(expr) { return ContentMatch.parse(expr, schema.nodes) } +function get(expr: string) { return ContentMatch.parse(expr, schema.nodes) } -function match(expr, types) { +function match(expr: string, types: string) { let m = get(expr), ts = types ? types.split(" ").map(t => schema.nodes[t]) : [] - for (let i = 0; m && i < ts.length; i++) m = m.matchType(ts[i]) + for (let i = 0; m && i < ts.length; i++) m = m.matchType(ts[i])! return m && m.validEnd } -function valid(expr, types) { ist(match(expr, types)) } -function invalid(expr, types) { ist(!match(expr, types)) } +function valid(expr: string, types: string) { ist(match(expr, types)) } +function invalid(expr: string, types: string) { ist(!match(expr, types)) } -function fill(expr, before, after, result) { - let filled = get(expr).matchFragment(before.content).fillBefore(after.content, true) +function fill(expr: string, before: Node, after: Node, result: Node | null) { + let filled = get(expr).matchFragment(before.content)!.fillBefore(after.content, true) if (result) ist(filled, result.content, eq) else ist(!filled) } -function fill3(expr, before, mid, after, left, right) { +function fill3(expr: string, before: Node, mid: Node, after: Node, left: Node | null, right?: Node) { let content = get(expr) - let a = content.matchFragment(before.content).fillBefore(mid.content) - let b = a && content.matchFragment(before.content.append(a).append(mid.content)).fillBefore(after.content, true) + let a = content.matchFragment(before.content)!.fillBefore(mid.content) + let b = a && content.matchFragment(before.content.append(a).append(mid.content))!.fillBefore(after.content, true) if (left) { ist(a, left.content, eq) - ist(b, right.content, eq) + ist(b, right!.content, eq) } else { ist(!b) } @@ -85,24 +85,24 @@ describe("ContentMatch", () => { describe("fillBefore", () => { it("returns the empty fragment when things match", () => - fill("paragraph horizontal_rule paragraph", doc(p(), hr), doc(p()), doc())) + fill("paragraph horizontal_rule paragraph", doc(p(), hr()), doc(p()), doc())) it("adds a node when necessary", () => - fill("paragraph horizontal_rule paragraph", doc(p()), doc(p()), doc(hr))) + fill("paragraph horizontal_rule paragraph", doc(p()), doc(p()), doc(hr()))) - it("accepts an asterisk across the bound", () => fill("hard_break*", p(br), p(br), p())) + it("accepts an asterisk across the bound", () => fill("hard_break*", p(br()), p(br()), p())) - it("accepts an asterisk only on the left", () => fill("hard_break*", p(br), p(), p())) + it("accepts an asterisk only on the left", () => fill("hard_break*", p(br()), p(), p())) - it("accepts an asterisk only on the right", () => fill("hard_break*", p(), p(br), p())) + it("accepts an asterisk only on the right", () => fill("hard_break*", p(), p(br()), p())) it("accepts an asterisk with no elements", () => fill("hard_break*", p(), p(), p())) - it("accepts a plus across the bound", () => fill("hard_break+", p(br), p(br), p())) + it("accepts a plus across the bound", () => fill("hard_break+", p(br()), p(br()), p())) - it("adds an element for a content-less plus", () => fill("hard_break+", p(), p(), p(br))) + it("adds an element for a content-less plus", () => fill("hard_break+", p(), p(), p(br()))) - it("fails for a mismatched plus", () => fill("hard_break+", p(), p(img), null)) + it("fails for a mismatched plus", () => fill("hard_break+", p(), p(img()), null)) it("accepts asterisk with content on both sides", () => fill("heading* paragraph*", doc(h1()), doc(p()), doc())) @@ -112,17 +112,17 @@ describe("ContentMatch", () => { it("accepts plus with no content after", () => fill("heading+ paragraph+", doc(h1()), doc(), doc(p()))) - it("adds elements to match a count", () => fill("hard_break{3}", p(br), p(br), p(br))) + it("adds elements to match a count", () => fill("hard_break{3}", p(br()), p(br()), p(br()))) - it("fails when there are too many elements", () => fill("hard_break{3}", p(br, br), p(br, br), null)) + it("fails when there are too many elements", () => fill("hard_break{3}", p(br(), br()), p(br(), br()), null)) it("adds elements for two counted groups", () => fill("code_block{2} paragraph{2}", doc(pre()), doc(p()), doc(pre(), p()))) - it("doesn't include optional elements", () => fill("heading paragraph? horizontal_rule", doc(h1()), doc(), doc(hr))) + it("doesn't include optional elements", () => fill("heading paragraph? horizontal_rule", doc(h1()), doc(), doc(hr()))) it("completes a sequence", () => fill3("paragraph horizontal_rule paragraph horizontal_rule paragraph", - doc(p()), doc(p()), doc(p()), doc(hr), doc(hr))) + doc(p()), doc(p()), doc(p()), doc(hr()), doc(hr()))) it("accepts plus across two bounds", () => fill3("code_block+ paragraph+", diff --git a/test/test-diff.js b/test/test-diff.ts similarity index 89% rename from test/test-diff.js rename to test/test-diff.ts index be60403..0e98a11 100644 --- a/test/test-diff.js +++ b/test/test-diff.ts @@ -1,10 +1,11 @@ -const {doc, blockquote, h1, h2, p, em, strong} = require("prosemirror-test-builder") -const ist = require("ist") +import {doc, blockquote, h1, h2, p, em, strong} from "prosemirror-test-builder" +import {Node} from "prosemirror-model" +import ist from "ist" describe("Fragment", () => { describe("findDiffStart", () => { - function start(a, b) { - ist(a.content.findDiffStart(b.content), a.tag.a) + function start(a: Node, b: Node) { + ist(a.content.findDiffStart(b.content), (a as any).tag.a) } it("returns null for identical nodes", () => @@ -45,9 +46,9 @@ describe("Fragment", () => { }) describe("findDiffEnd", () => { - function end(a, b) { + function end(a: Node, b: Node) { let found = a.content.findDiffEnd(b.content) - ist(found && found.a, a.tag.a) + ist(found && found.a, (a as any).tag.a) } it("returns null when there is no difference", () => diff --git a/test/test-dom.js b/test/test-dom.ts similarity index 86% rename from test/test-dom.js rename to test/test-dom.ts index c8ef509..604c447 100644 --- a/test/test-dom.js +++ b/test/test-dom.ts @@ -1,26 +1,26 @@ -const {schema, eq, doc, blockquote, pre, h1, h2, p, li, ol, ul, em, strong, code, a, br, img, hr, - builders} = require("prosemirror-test-builder") -const ist = require("ist") -const {DOMParser, DOMSerializer, Slice, Fragment, Schema} = require("..") +import {schema, eq, doc, blockquote, pre, h1, h2, p, li, ol, ul, em, strong, code, a, br, img, hr, + builders} from "prosemirror-test-builder" +import ist from "ist" +import {DOMParser, DOMSerializer, Slice, Fragment, Schema, Node as PMNode, Mark, + ParseOptions, ParseRule} from "prosemirror-model" -// declare global: window -let document = typeof window == "undefined" ? (new (require("jsdom").JSDOM)).window.document : window.document -const xmlDocument = typeof window == "undefined" - ? (new (require("jsdom").JSDOM)("", {contentType: "application/xml"})).window.document - : window.document +// @ts-ignore +import {JSDOM} from "jsdom" +const document = new JSDOM().window.document +const xmlDocument = new JSDOM("", {contentType: "application/xml"}).window.document const parser = DOMParser.fromSchema(schema) const serializer = DOMSerializer.fromSchema(schema) describe("DOMParser", () => { describe("parse", () => { - function domFrom(html, document_ = document) { + function domFrom(html: string, document_ = document) { let dom = document_.createElement("div") dom.innerHTML = html return dom } - function test(doc, html, document_ = document) { + function test(doc: PMNode, html: string, document_ = document) { return () => { let derivedDOM = document_.createElement("div"), schema = doc.type.schema derivedDOM.appendChild(DOMSerializer.fromSchema(schema).serializeFragment(doc.content, {document: document_})) @@ -36,7 +36,7 @@ describe("DOMParser", () => { "

hello

")) it("can represent a line break", - test(doc(p("hi", br, "there")), + test(doc(p("hi", br(), "there")), "

hi
there

")) it("can represent an image", @@ -80,7 +80,7 @@ describe("DOMParser", () => { "
some code

and

")) it("supports leaf nodes in marks", - test(doc(p(em("hi", br, "x"))), + test(doc(p(em("hi", br(), "x"))), "

hi
x

")) it("doesn't collapse non-breaking spaces", @@ -95,7 +95,7 @@ describe("DOMParser", () => { toDOM() { return ["div", {class: "comment"}, 0] } }) }) - let b = builders(commentSchema) + let b = builders(commentSchema) as any test(b.doc(b.paragraph("one"), b.comment(b.paragraph("two"), b.paragraph(b.strong("three"))), b.paragraph("four")), "

one

two

three

four

")() }) @@ -107,21 +107,21 @@ describe("DOMParser", () => { attrs: { id: { default: null }}, parseDOM: [{ tag: "span.comment", - getAttrs(dom) { return { id: parseInt(dom.getAttribute('data-id'), 10) } } + getAttrs(dom) { return { id: parseInt((dom as HTMLElement).getAttribute('data-id')!, 10) } } }], excludes: '', - toDOM(mark) { return ["span", {class: "comment", "data-id": mark.attrs.id }, 0] } + toDOM(mark: Mark) { return ["span", {class: "comment", "data-id": mark.attrs.id }, 0] } }) }) let b = builders(commentSchema) test(b.schema.nodes.doc.createAndFill(undefined, [ - b.schema.nodes.paragraph.createAndFill(undefined, [ - b.schema.text('double comment', [ - b.schema.marks.comment.create({ id: 1 }), - b.schema.marks.comment.create({ id: 2 }) - ]) - ]) - ]), + b.schema.nodes.paragraph.createAndFill(undefined, [ + b.schema.text('double comment', [ + b.schema.marks.comment.create({ id: 1 }), + b.schema.marks.comment.create({ id: 2 }) + ])! + ])! + ])!, "

double comment

")() }) @@ -134,7 +134,7 @@ describe("DOMParser", () => { spanning: false }) }) - let b = builders(markSchema) + let b = builders(markSchema) as any test(b.doc(b.paragraph(b.test("a", b.image({src: "x"}), "b"))), "

ab

")() }) @@ -151,7 +151,7 @@ describe("DOMParser", () => { }, }) - let b = builders(xmlnsSchema) + let b = builders(xmlnsSchema) as any let d = b.doc(b.svg()) test(d, "", xmlDocument)() @@ -162,7 +162,7 @@ describe("DOMParser", () => { ist(dom.querySelector('use').attributes[0].namespaceURI, 'http://www.w3.org/1999/xlink') }) - function recover(html, doc, options) { + function recover(html: string, doc: PMNode, options?: ParseOptions) { return () => { let dom = document.createElement("div") dom.innerHTML = html @@ -200,7 +200,7 @@ describe("DOMParser", () => { it("removes whitespace after a hard break", recover("

hello
\n world

", - doc(p("hello", br, "world")))) + doc(p("hello", br(), "world")))) it("converts br nodes to newlines when they would otherwise be ignored", recover("
foo
bar
", @@ -212,7 +212,7 @@ describe("DOMParser", () => { it("moves nodes up when they don't fit the current context", recover("
hello
bye
", - doc(p("hello"), hr, p("bye")))) + doc(p("hello"), hr(), p("bye")))) it("doesn't ignore whitespace-only text nodes", recover("

one two

", @@ -273,7 +273,7 @@ describe("DOMParser", () => { it("doesn't ignore whitespace-only nodes in preserveWhitespace full mode", recover(" x", doc(p(" x")), {preserveWhitespace: "full"})) - function parse(html, options, doc) { + function parse(html: string, options: ParseOptions, doc: PMNode) { return () => { let dom = document.createElement("div") dom.innerHTML = html @@ -283,12 +283,12 @@ describe("DOMParser", () => { } it("accepts the topNode option", - parse("
  • wow
  • such
  • ", {topNode: schema.nodes.bullet_list.createAndFill()}, + parse("
  • wow
  • such
  • ", {topNode: schema.nodes.bullet_list.createAndFill()!}, ul(li(p("wow")), li(p("such"))))) - let item = schema.nodes.list_item.createAndFill() + let item = schema.nodes.list_item.createAndFill()! it("accepts the topMatch option", - parse("
    • x
    ", {topNode: item, topMatch: item.contentMatchAt(1)}, + parse("
    • x
    ", {topNode: item, topMatch: item.contentMatchAt(1)!}, li(ul(li(p("x")))))) it("accepts from and to options", @@ -299,7 +299,7 @@ describe("DOMParser", () => { parse("foo bar", {preserveWhitespace: true}, doc(p("foo bar")))) - function open(html, nodes, openStart, openEnd, options) { + function open(html: string, nodes: (string | PMNode)[], openStart: number, openEnd: number, options?: ParseOptions) { return () => { let dom = document.createElement("div") dom.innerHTML = html @@ -315,7 +315,7 @@ describe("DOMParser", () => { open("foo

    bar

    ", ["foo", p("bar")], 0, 1)) it("will open all the way to the inner nodes", - open("
    • foo
    • bar
    ", [ul(li(p("foo")), li(p("bar", br)))], 3, 3)) + open("
    • foo
    • bar
    ", [ul(li(p("foo")), li(p("bar", br())))], 3, 3)) it("accepts content open to the left", open("
    • a
  • ", [li(ul(li(p("a"))))], 4, 4)) @@ -395,11 +395,11 @@ describe("DOMParser", () => { ), 1, 1), eq) }) - function find(html, doc) { + function find(html: string, doc: PMNode) { return () => { let dom = document.createElement("div") dom.innerHTML = html - let tag = dom.querySelector("var"), prev = tag.previousSibling, next = tag.nextSibling, pos + let tag = dom.querySelector("var"), prev = tag.previousSibling!, next = tag.nextSibling, pos if (prev && next && prev.nodeType == 3 && next.nodeType == 3) { pos = {node: prev, offset: prev.nodeValue.length} prev.nodeValue += next.nodeValue @@ -412,7 +412,7 @@ describe("DOMParser", () => { findPositions: [pos] }) ist(result, doc, eq) - ist(pos.pos, doc.tag.a) + ist((pos as any).pos, (doc as any).tag.a) } } @@ -450,66 +450,68 @@ describe("DOMParser", () => { test(quoteSchema.node("blockquote", null, quoteSchema.node("paragraph", null, quoteSchema.text("hello"))), "

    hello

    ")) - function contextParser(context) { - return new DOMParser(schema, [{tag: "foo", node: "horizontal_rule", context}].concat(DOMParser.schemaRules(schema))) + function contextParser(context: string) { + return new DOMParser(schema, [{tag: "foo", node: "horizontal_rule", context} as ParseRule] + .concat(DOMParser.schemaRules(schema) as ParseRule[])) } it("recognizes context restrictions", () => { ist(contextParser("blockquote/").parse(domFrom("

    ")), - doc(blockquote(hr, p())), eq) + doc(blockquote(hr(), p())), eq) }) it("accepts group names in contexts", () => { ist(contextParser("block/").parse(domFrom("

    ")), - doc(blockquote(hr, p())), eq) + doc(blockquote(hr(), p())), eq) }) it("understands nested context restrictions", () => { ist(contextParser("blockquote/ordered_list//") .parse(domFrom("
    1. a

    ")), - doc(blockquote(ol(li(p("a"), hr)))), eq) + doc(blockquote(ol(li(p("a"), hr())))), eq) }) it("understands double slashes in context restrictions", () => { ist(contextParser("blockquote//list_item/") .parse(domFrom("
    1. a

    ")), - doc(blockquote(ol(li(p("a"), hr)))), eq) + doc(blockquote(ol(li(p("a"), hr())))), eq) }) it("understands pipes in context restrictions", () => { ist(contextParser("list_item/|blockquote/") .parse(domFrom("

    1. a

    ")), - doc(blockquote(p(), hr), ol(li(p("a"), hr))), eq) + doc(blockquote(p(), hr()), ol(li(p("a"), hr()))), eq) }) it("uses the passed context", () => { - let cxDoc = doc(blockquote("", hr)) + let cxDoc = doc(blockquote("", hr())) ist(contextParser("doc//blockquote/").parse(domFrom("
    "), { topNode: blockquote(), - context: cxDoc.resolve(cxDoc.tag.a) - }), blockquote(blockquote(hr)), eq) + context: cxDoc.resolve((cxDoc as any).tag.a) + }), blockquote(blockquote(hr())), eq) }) it("uses the passed context when parsing a slice", () => { - let cxDoc = doc(blockquote("
    ", hr)) + let cxDoc = doc(blockquote("", hr())) ist(contextParser("doc//blockquote/").parseSlice(domFrom(""), { - context: cxDoc.resolve(cxDoc.tag.a) - }), new Slice(blockquote(hr).content, 0, 0), eq) + context: cxDoc.resolve((cxDoc as any).tag.a) + }), new Slice(blockquote(hr()).content, 0, 0), eq) }) it("can close parent nodes from a rule", () => { - let closeParser = new DOMParser(schema, [{tag: "br", closeParent: true}].concat(DOMParser.schemaRules(schema))) + let closeParser = new DOMParser(schema, [{tag: "br", closeParent: true} as ParseRule] + .concat(DOMParser.schemaRules(schema))) ist(closeParser.parse(domFrom("

    one
    two

    ")), doc(p("one"), p("two")), eq) }) it("supports non-consuming node rules", () => { - let parser = new DOMParser(schema, [{tag: "ol", consuming: false, node: "blockquote"}] + let parser = new DOMParser(schema, [{tag: "ol", consuming: false, node: "blockquote"} as ParseRule] .concat(DOMParser.schemaRules(schema))) ist(parser.parse(domFrom("

      one

    ")), doc(blockquote(ol(li(p("one"))))), eq) }) it("supports non-consuming style rules", () => { - let parser = new DOMParser(schema, [{style: "font-weight", consuming: false, mark: "em"}] + let parser = new DOMParser(schema, [{style: "font-weight", consuming: false, mark: "em"} as ParseRule] .concat(DOMParser.schemaRules(schema))) ist(parser.parse(domFrom("

    one

    ")), doc(p(em(strong("one")))), eq) }) @@ -542,7 +544,7 @@ describe("DOMParser", () => { ist(DOMParser.schemaRules(schema).map(r => r.tag).join(" "), "em bar foo i") }) - function nsParse(doc, namespace) { + function nsParse(doc: Node, namespace?: string) { let schema = new Schema({ nodes: {doc: {content: "h*"}, text: {}, h: {parseDOM: [{tag: "h", namespace}]}} @@ -591,7 +593,7 @@ describe("DOMParser", () => { let doc = xmlDocument.createElement("doc") let h = xmlDocument.createElementNS(null, "h") doc.appendChild(h) - ist(nsParse(doc, null).childCount, 1) + ist(nsParse(doc).childCount, 1) }) }) }) @@ -600,12 +602,12 @@ describe("DOMSerializer", () => { let noEm = new DOMSerializer(serializer.nodes, Object.assign({}, serializer.marks, {em: null})) it("can omit a mark", () => { - ist(noEm.serializeNode(p("foo", em("bar"), strong("baz")), {document}).innerHTML, + ist((noEm.serializeNode(p("foo", em("bar"), strong("baz")), {document}) as HTMLElement).innerHTML, "foobarbaz") }) it("doesn't split other marks for omitted marks", () => { - ist(noEm.serializeNode(p("foo", code("bar"), em(code("baz"), "quux"), "xyz"), {document}).innerHTML, + ist((noEm.serializeNode(p("foo", code("bar"), em(code("baz"), "quux"), "xyz"), {document}) as HTMLElement).innerHTML, "foobarbazquuxxyz") }) @@ -613,7 +615,8 @@ describe("DOMSerializer", () => { let deepEm = new DOMSerializer(serializer.nodes, Object.assign({}, serializer.marks, { em() { return ["em", ["i", {"data-emphasis": true}, 0]] } })) - ist(deepEm.serializeNode(p(strong("foo", code("bar"), em(code("baz"))), em("quux"), "xyz"), {document}).innerHTML, + let node = deepEm.serializeNode(p(strong("foo", code("bar"), em(code("baz"))), em("quux"), "xyz"), {document}) + ist((node as HTMLElement).innerHTML, "foobarbazquuxxyz") }) }) diff --git a/test/test-mark.js b/test/test-mark.ts similarity index 94% rename from test/test-mark.js rename to test/test-mark.ts index 8ec5de0..7922b8d 100644 --- a/test/test-mark.js +++ b/test/test-mark.ts @@ -1,10 +1,10 @@ -const {Mark, Schema} = require("..") -const {schema, doc, p, em, a} = require("prosemirror-test-builder") -const ist = require("ist") +import {Mark, Schema, Node} from "prosemirror-model" +import {schema, doc, p, em, a} from "prosemirror-test-builder" +import ist from "ist" let em_ = schema.mark("em") let strong = schema.mark("strong") -let link = (href, title) => schema.mark("link", {href, title}) +let link = (href: string, title?: string) => schema.mark("link", {href, title}) let code = schema.mark("code") let customSchema = new Schema({ @@ -120,8 +120,8 @@ describe("Mark", () => { }) describe("ResolvedPos.marks", () => { - function isAt(doc, mark, result) { - ist(mark.isInSet(doc.resolve(doc.tag.a).marks()), result) + function isAt(doc: Node, mark: Mark, result: boolean) { + ist(mark.isInSet(doc.resolve((doc as any).tag.a).marks()), result) } it("recognizes a mark exists inside marked text", () => diff --git a/test/test-node.js b/test/test-node.ts similarity index 79% rename from test/test-node.js rename to test/test-node.ts index 69a2b96..d2be659 100644 --- a/test/test-node.js +++ b/test/test-node.ts @@ -1,6 +1,6 @@ -const ist = require("ist") -const {Fragment, Schema} = require("..") -const {schema, eq, doc, blockquote, p, li, ul, em, strong, code, a, br, hr, img} = require("prosemirror-test-builder") +import ist from "ist" +import {Fragment, Schema, Node} from "prosemirror-model" +import {schema, eq, doc, blockquote, p, li, ul, em, strong, code, a, br, hr, img} from "prosemirror-test-builder" let customSchema = new Schema({ nodes: { @@ -19,7 +19,7 @@ describe("Node", () => { }) it("shows inline children", () => { - ist(doc(p("foo", img, br, "bar")).toString(), + ist(doc(p("foo", img(), br(), "bar")).toString(), 'doc(paragraph("foo", image, hard_break, "bar"))') }) @@ -30,8 +30,8 @@ describe("Node", () => { }) describe("cut", () => { - function cut(doc, cut) { - ist(doc.cut(doc.tag.a || 0, doc.tag.b), cut, eq) + function cut(doc: Node, cut: Node) { + ist(doc.cut((doc as any).tag.a || 0, (doc as any).tag.b), cut, eq) } it("extracts a full block", () => @@ -55,17 +55,17 @@ describe("Node", () => { doc(blockquote(p("bar"))))) it("preserves marks", () => - cut(doc(p("foo", em("ba
    r", img, strong("baz"), br), "quux", code("xyz"))), - doc(p(em("r", img, strong("baz"), br), "qu")))) + cut(doc(p("foo", em("bar", img(), strong("baz"), br()), "quux", code("xyz"))), + doc(p(em("r", img(), strong("baz"), br()), "qu")))) }) describe("between", () => { - function between(doc, ...nodes) { + function between(doc: Node, ...nodes: string[]) { let i = 0 - doc.nodesBetween(doc.tag.a, doc.tag.b, (node, pos) => { + doc.nodesBetween((doc as any).tag.a, (doc as any).tag.b, (node, pos) => { if (i == nodes.length) throw new Error("More nodes iterated than listed (" + node.type.name + ")") - let compare = node.isText ? node.text : node.type.name + let compare = node.isText ? node.text! : node.type.name if (compare != nodes[i++]) throw new Error("Expected " + JSON.stringify(nodes[i - 1]) + ", got " + JSON.stringify(compare)) if (!node.isText && doc.nodeAt(pos) != node) @@ -82,20 +82,17 @@ describe("Node", () => { "blockquote", "bullet_list", "list_item", "paragraph", "foo", "paragraph", "b")) it("iterates over inline nodes", () => - between(doc(p(em("x"), "foo", em("bar", img, strong("baz"), br), "quux", code("xyz"))), + between(doc(p(em("x"), "foo", em("bar", img(), strong("baz"), br()), "quux", code("xyz"))), "paragraph", "foo", "bar", "image", "baz", "hard_break", "quux", "xyz")) }) describe("textBetween", () => { it("works when passing a custom function as leafText", () => { - const d = doc(p("foo", img, br)) + const d = doc(p("foo", img(), br())) ist(d.textBetween(0, d.content.size, '', (node) => { - if (node.type.name === 'image') { - return '' - } - if (node.type.name === 'hard_break') { - return '' - } + if (node.type.name === 'image') return '' + if (node.type.name === 'hard_break') return '' + return "" }), 'foo') }) }) @@ -116,7 +113,7 @@ describe("Node", () => { }) describe("from", () => { - function from(arg, expect) { + function from(arg: Node | Node[] | Fragment | null, expect: Node) { ist(expect.copy(Fragment.from(arg)), expect, eq) } @@ -137,7 +134,7 @@ describe("Node", () => { }) describe("toJSON", () => { - function roundTrip(doc) { + function roundTrip(doc: Node) { ist(schema.nodeFromJSON(doc.toJSON()), doc, eq) } @@ -145,11 +142,11 @@ describe("Node", () => { it("can serialize marks", () => roundTrip(doc(p("foo", em("bar", strong("baz")), " ", a("x"))))) - it("can serialize inline leaf nodes", () => roundTrip(doc(p("foo", em(img, "bar"))))) + it("can serialize inline leaf nodes", () => roundTrip(doc(p("foo", em(img(), "bar"))))) - it("can serialize block leaf nodes", () => roundTrip(doc(p("a"), hr, p("b"), p()))) + it("can serialize block leaf nodes", () => roundTrip(doc(p("a"), hr(), p("b"), p()))) - it("can serialize nested nodes", () => roundTrip(doc(blockquote(ul(li(p("a"), p("b")), li(p(img))), p("c")), p("d")))) + it("can serialize nested nodes", () => roundTrip(doc(blockquote(ul(li(p("a"), p("b")), li(p(img()))), p("c")), p("d")))) }) describe("toString", () => { diff --git a/test/test-replace.js b/test/test-replace.ts similarity index 85% rename from test/test-replace.js rename to test/test-replace.ts index 4cccea3..3df3990 100644 --- a/test/test-replace.js +++ b/test/test-replace.ts @@ -1,12 +1,12 @@ -const {Slice} = require("..") -const {eq, doc, blockquote, h1, p, ul, li} = require("prosemirror-test-builder") -const ist = require("ist") +import {Slice, Node} from "prosemirror-model" +import {eq, doc, blockquote, h1, p, ul, li} from "prosemirror-test-builder" +import ist from "ist" describe("Node", () => { describe("replace", () => { - function rpl(doc, insert, expected) { - let slice = insert ? insert.slice(insert.tag.a, insert.tag.b) : Slice.empty - ist(doc.replace(doc.tag.a, doc.tag.b, slice), expected, eq) + function rpl(doc: Node, insert: Node | null, expected: Node) { + let slice = insert ? insert.slice((insert as any).tag.a, (insert as any).tag.b) : Slice.empty + ist(doc.replace((doc as any).tag.a, (doc as any).tag.b, slice), expected, eq) } it("joins on delete", () => @@ -85,9 +85,9 @@ describe("Node", () => { doc(p("foobaz"), ""), doc(h1("baz")))) - function bad(doc, insert, pattern) { - let slice = insert ? insert.slice(insert.tag.a, insert.tag.b) : Slice.empty - ist.throws(() => doc.replace(doc.tag.a, doc.tag.b, slice), new RegExp(pattern, "i")) + function bad(doc: Node, insert: Node | null, pattern: string) { + let slice = insert ? insert.slice((insert as any).tag.a, (insert as any).tag.b) : Slice.empty + ist.throws(() => doc.replace((doc as any).tag.a, (doc as any).tag.b, slice), new RegExp(pattern, "i")) } it("doesn't allow the left side to be too deep", () => diff --git a/test/test-resolve.js b/test/test-resolve.ts similarity index 89% rename from test/test-resolve.js rename to test/test-resolve.ts index 06b80ad..74f6f0f 100644 --- a/test/test-resolve.js +++ b/test/test-resolve.ts @@ -1,5 +1,5 @@ -const {doc, p, em, blockquote} = require("prosemirror-test-builder") -const ist = require("ist") +import {doc, p, em, blockquote} from "prosemirror-test-builder" +import ist from "ist" const testDoc = doc(p("ab"), blockquote(p(em("cd"), "ef"))) const _doc = {node: testDoc, start: 0, end: 12} @@ -10,7 +10,7 @@ const _p2 = {node: _blk.node.child(0), start: 6, end: 10} describe("Node", () => { describe("resolve", () => { it("should reflect the document structure", () => { - let expected = { + let expected: {[pos: number]: any} = { 0: [_doc, 0, null, _p1.node], 1: [_doc, _p1, 0, null, "ab"], 2: [_doc, _p1, 1, "a", "b"], @@ -39,9 +39,9 @@ describe("Node", () => { } } ist($pos.parentOffset, exp[exp.length - 3]) - let before = $pos.nodeBefore, eBefore = exp[exp.length - 2] + let before = $pos.nodeBefore!, eBefore = exp[exp.length - 2] ist(typeof eBefore == "string" ? before.textContent : before, eBefore) - let after = $pos.nodeAfter, eAfter = exp[exp.length - 1] + let after = $pos.nodeAfter!, eAfter = exp[exp.length - 1] ist(typeof eAfter == "string" ? after.textContent : after, eAfter) } }) diff --git a/test/test-slice.js b/test/test-slice.ts similarity index 89% rename from test/test-slice.js rename to test/test-slice.ts index 14f7097..33ba627 100644 --- a/test/test-slice.js +++ b/test/test-slice.ts @@ -1,10 +1,11 @@ -const {doc, p, li, ul, em, a, blockquote} = require("prosemirror-test-builder") -const ist = require("ist") +import {doc, p, li, ul, em, a, blockquote} from "prosemirror-test-builder" +import {Node} from "prosemirror-model" +import ist from "ist" describe("Node", () => { describe("slice", () => { - function t(doc, expect, openStart, openEnd) { - let slice = doc.slice(doc.tag.a || 0, doc.tag.b) + function t(doc: Node, expect: Node, openStart: number, openEnd: number) { + let slice = doc.slice((doc as any).tag.a || 0, (doc as any).tag.b) ist(slice.content.eq(expect.content)) ist(slice.openStart, openStart) ist(slice.openEnd, openEnd) @@ -76,7 +77,7 @@ describe("Node", () => { it("can include parents", () => { let d = doc(blockquote(p("foo"), p("bar"))) - let slice = d.slice(d.tag.a, d.tag.b, true) + let slice = d.slice((d as any).tag.a, (d as any).tag.b, true) ist(slice.toString(), '(2,2)') }) }) From 696f1e7e8efb209859049ad030ca6e7dd5f4b89d Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sat, 21 May 2022 12:51:41 +0200 Subject: [PATCH 049/112] Don't directly reference the global Node type To avoid having our local Node type renamed to Node by the .d.ts bundler. --- src/dom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dom.ts b/src/dom.ts index 304cd1c..0f1f6fe 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -1 +1 @@ -export type DOMNode = Node +export type DOMNode = InstanceType From 26c634ffff8ad6544fda12ed70c99f12a65959f3 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 25 May 2022 07:33:01 +0200 Subject: [PATCH 050/112] Make sure ParseContext.open doesn't become negative FIX: Fix a crash in DOM parsing. See https://discuss.prosemirror.net/t/parse-issue-with-closeparent-option-enabled/4636 --- src/from_dom.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/from_dom.ts b/src/from_dom.ts index b0568b6..7e771e6 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -557,7 +557,7 @@ class ParseContext { this.findAround(dom, contentDOM, true) this.addAll(contentDOM) } - if (sync) { this.sync(startIn); this.open-- } + if (sync && this.sync(startIn)) this.open-- if (mark) this.removePendingMark(mark, startIn) } @@ -657,8 +657,9 @@ class ParseContext { sync(to: NodeContext) { for (let i = this.open; i >= 0; i--) if (this.nodes[i] == to) { this.open = i - return + return true } + return false } get currentPos() { From 10f2e2e85bcbcd79eacc2cb1620f946d5d3e6ab2 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 30 May 2022 14:42:28 +0200 Subject: [PATCH 051/112] Mark version 1.17.0 --- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd66d3c..11c30e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.17.0 (2022-05-30) + +### Bug fixes + +Fix a crash in DOM parsing. + +### New features + +Include TypeScript type declarations. + ## 1.16.1 (2021-12-29) ### Bug fixes diff --git a/package.json b/package.json index 9439fec..c0f3139 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.16.1", + "version": "1.17.0", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From de1b852a655c2a5af66921652d06fad99e7e8128 Mon Sep 17 00:00:00 2001 From: ocavue Date: Tue, 7 Jun 2022 15:54:12 +0800 Subject: [PATCH 052/112] Allow a leaf node to customize its textContent FEATURE: Node specs for leaf nodes now support a property `leafText` which, when given, will be used by `textContent` and `textBetween` to serialize the node. --- src/fragment.ts | 10 +++++++--- src/node.ts | 11 ++++++++--- src/schema.ts | 6 ++++++ test/test-node.ts | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/fragment.ts b/src/fragment.ts index 1fdf7c2..731e606 100644 --- a/src/fragment.ts +++ b/src/fragment.ts @@ -57,9 +57,13 @@ export class Fragment { if (node.isText) { text += node.text!.slice(Math.max(from, pos) - pos, to - pos) separated = !blockSeparator - } else if (node.isLeaf && leafText) { - text += typeof leafText === 'function' ? leafText(node): leafText - separated = !blockSeparator + } else if (node.isLeaf) { + if (leafText) { + text += typeof leafText === "function" ? leafText(node) : leafText; + } else if (node.type.spec.leafText) { + text += node.type.spec.leafText(node); + } + separated = !blockSeparator; } else if (!separated && node.isBlock) { text += blockSeparator separated = true diff --git a/src/node.ts b/src/node.ts index 845c359..aec9c19 100644 --- a/src/node.ts +++ b/src/node.ts @@ -85,12 +85,17 @@ export class Node { /// Concatenates all the text nodes found in this fragment and its /// children. - get textContent() { return this.textBetween(0, this.content.size, "") } + 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. When `leafText` is given, it'll be - /// inserted for every non-text leaf node encountered. + /// 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) diff --git a/src/schema.ts b/src/schema.ts index 2e087f9..77747be 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -427,6 +427,12 @@ export interface NodeSpec { /// 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 + /// Node specs may include arbitrary properties that can be read by /// other code via [`NodeType.spec`](#model.NodeType.spec). [key: string]: any diff --git a/test/test-node.ts b/test/test-node.ts index d2be659..86ef863 100644 --- a/test/test-node.ts +++ b/test/test-node.ts @@ -5,8 +5,13 @@ import {schema, eq, doc, blockquote, p, li, ul, em, strong, code, a, br, hr, img let customSchema = new Schema({ nodes: { doc: {content: "paragraph+"}, - paragraph: {content: "text*"}, + paragraph: {content: "(text|contact)*"}, text: { toDebugString() { return 'custom_text' } }, + contact: { + inline: true, + attrs: { name: {}, email: {} }, + leafText(node: Node) { return `${node.attrs.name} <${node.attrs.email}>` } + }, hard_break: { toDebugString() { return 'custom_hard_break' } } }, }) @@ -95,6 +100,26 @@ describe("Node", () => { return "" }), 'foo') }) + + it("works with leafText", () => { + const d = customSchema.nodes.doc.createChecked({}, [ + customSchema.nodes.paragraph.createChecked({}, [ + customSchema.text("Hello "), + customSchema.nodes.contact.createChecked({ name: "Alice", email: "alice@example.com" }) + ]) + ]) + ist(d.textBetween(0, d.content.size), 'Hello Alice ') + }) + + it("should ignore leafText when passing a custom leafText", () => { + const d = customSchema.nodes.doc.createChecked({}, [ + customSchema.nodes.paragraph.createChecked({}, [ + customSchema.text("Hello "), + customSchema.nodes.contact.createChecked({ name: "Alice", email: "alice@example.com" }) + ]) + ]) + ist(d.textBetween(0, d.content.size, '', ''), 'Hello ') + }) }) describe("textContent", () => { @@ -165,4 +190,14 @@ describe("Node", () => { ) ) }) + + describe("leafText", () => { + it("should custom the textContent of a leaf node", () => { + let contact = customSchema.nodes.contact.createChecked({ name: "Bob", email: "bob@example.com" }) + let paragraph = customSchema.nodes.paragraph.createChecked({}, [customSchema.text('Hello '), contact]) + + ist(contact.textContent, "Bob ") + ist(paragraph.textContent, "Hello Bob ") + }) + }) }) From c10cf989de63193bb324e86180c9f648dfc4b6e3 Mon Sep 17 00:00:00 2001 From: Christopher Lenz Date: Tue, 7 Jun 2022 10:07:10 +0200 Subject: [PATCH 053/112] Add stronger typing for Schema ... allowing the statically known names of mark and node types to be accessed without error even with the TypeScript option `noUncheckedIndexedAccess` enabled. In addition, this should also make the node and mark types available for auto-completion in the editor. See https://discuss.prosemirror.net/t/prosemirror-is-now-a-typescript-project/4624/34 (#67) FEATURE: Add optional type parameters to `Schema` for the node and mark names. --- src/schema.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 77747be..c22ac1e 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -205,7 +205,7 @@ export class NodeType { } /// @internal - static compile(nodes: OrderedMap, schema: Schema): {readonly [name: string]: NodeType} { + static compile(nodes: OrderedMap, schema: Schema): {readonly [name in N]: NodeType} { let result = Object.create(null) nodes.forEach((name, spec) => result[name] = new NodeType(name, schema, spec)) @@ -305,20 +305,20 @@ export class MarkType { /// An object describing a schema, as passed to the [`Schema`](#model.Schema) /// constructor. -export interface SchemaSpec { +export interface SchemaSpec { /// 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: string]: NodeSpec} | OrderedMap, + nodes: {[name in N]: NodeSpec} | OrderedMap, /// 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: string]: MarkSpec} | OrderedMap + marks?: {[name in M]: MarkSpec} | OrderedMap /// The name of the default top-level node for the schema. Defaults /// to `"doc"`. @@ -501,7 +501,7 @@ export interface AttributeSpec { /// type](#model.MarkType) objects for the nodes and marks that may /// occur in conforming documents, and provides functionality for /// creating and deserializing such documents. -export class Schema { +export class Schema { /// The [spec](#model.SchemaSpec) on which the schema is based, /// with the added guarantee that its `nodes` and `marks` /// properties are @@ -514,13 +514,13 @@ export class Schema { } /// An object mapping the schema's node names to node type objects. - nodes: {readonly [name: string]: NodeType} + nodes: {readonly [name in N]: NodeType} & {readonly [key: string]: NodeType} /// A map from mark names to mark type objects. - marks: {readonly [name: string]: MarkType} + marks: {readonly [name in M]: MarkType} & {readonly [key: string]: MarkType} /// Construct a schema from a schema [specification](#model.SchemaSpec). - constructor(spec: SchemaSpec) { + constructor(spec: SchemaSpec) { this.spec = { nodes: OrderedMap.from(spec.nodes), marks: OrderedMap.from(spec.marks || {}), From d40def166290649ea81ed4f1fb8d5108536c32e3 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 7 Jun 2022 10:10:58 +0200 Subject: [PATCH 054/112] Clarify Schema type parameters --- src/schema.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index c22ac1e..157d93d 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -205,7 +205,7 @@ export class NodeType { } /// @internal - static compile(nodes: OrderedMap, schema: Schema): {readonly [name in N]: NodeType} { + static compile(nodes: OrderedMap, schema: Schema): {readonly [name in Nodes]: NodeType} { let result = Object.create(null) nodes.forEach((name, spec) => result[name] = new NodeType(name, schema, spec)) @@ -305,20 +305,20 @@ export class MarkType { /// An object describing a schema, as passed to the [`Schema`](#model.Schema) /// constructor. -export interface SchemaSpec { +export interface SchemaSpec { /// 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 N]: NodeSpec} | OrderedMap, + nodes: {[name in Nodes]: NodeSpec} | OrderedMap, /// 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 M]: MarkSpec} | OrderedMap + marks?: {[name in Marks]: MarkSpec} | OrderedMap /// The name of the default top-level node for the schema. Defaults /// to `"doc"`. @@ -501,7 +501,10 @@ export interface AttributeSpec { /// type](#model.MarkType) objects for the nodes and marks that may /// occur in conforming documents, and provides functionality for /// creating and deserializing such documents. -export class Schema { +/// +/// When given, the type parameters provide the names of the nodes and +/// marks in this schema. +export class Schema { /// The [spec](#model.SchemaSpec) on which the schema is based, /// with the added guarantee that its `nodes` and `marks` /// properties are @@ -514,13 +517,13 @@ export class Schema { } /// An object mapping the schema's node names to node type objects. - nodes: {readonly [name in N]: NodeType} & {readonly [key: string]: NodeType} + nodes: {readonly [name in Nodes]: NodeType} & {readonly [key: string]: NodeType} /// A map from mark names to mark type objects. - marks: {readonly [name in M]: MarkType} & {readonly [key: string]: MarkType} + marks: {readonly [name in Marks]: MarkType} & {readonly [key: string]: MarkType} /// Construct a schema from a schema [specification](#model.SchemaSpec). - constructor(spec: SchemaSpec) { + constructor(spec: SchemaSpec) { this.spec = { nodes: OrderedMap.from(spec.nodes), marks: OrderedMap.from(spec.marks || {}), From 278e717a482c24acabcbbfdc1389d446bee6c3df Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 7 Jun 2022 10:11:06 +0200 Subject: [PATCH 055/112] Mark version 1.18.0 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c30e7..c2479a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 1.18.0 (2022-06-07) + +### New features + +Node specs for leaf nodes now support a property `leafText` which, when given, will be used by `textContent` and `textBetween` to serialize the node. + +Add optional type parameters to `Schema` for the node and mark names. Clarify Schema type parameters + ## 1.17.0 (2022-05-30) ### Bug fixes diff --git a/package.json b/package.json index c0f3139..de4b643 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.17.0", + "version": "1.18.0", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 6789b335eaba4b13c9ba506c0e8bf5548ca40aba Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 8 Jun 2022 15:36:08 +0200 Subject: [PATCH 056/112] Explicitly specify the type of nodeFromJSON Since TypeScript somehow puts any in the .d.ts otherwise. Issue https://github.com/ProseMirror/prosemirror/issues/1283 --- src/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 157d93d..6d6d62c 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -598,13 +598,13 @@ export class Schema { /// Deserialize a node from its JSON representation. This method is /// bound. - nodeFromJSON(json: any) { + nodeFromJSON(json: any): Node { return Node.fromJSON(this, json) } /// Deserialize a mark from its JSON representation. This method is /// bound. - markFromJSON(json: any) { + markFromJSON(json: any): Mark { return Mark.fromJSON(this, json) } From bddb01a64f0524f7149de97e7ab6567ea3b482cc Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 13 Jun 2022 13:34:19 +0200 Subject: [PATCH 057/112] Make compatibleContent public Issue https://github.com/ProseMirror/prosemirror-view/pull/129 --- src/schema.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index 6d6d62c..9a08eae 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -116,7 +116,8 @@ export class NodeType { return false } - /// @internal + /// 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) } From eb9cc6f8c58b8c0e6500df82c305b32518d689ff Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 15 Jun 2022 09:05:25 +0200 Subject: [PATCH 058/112] Upgrade to orderedmap 2.0 FIX: Upgrade to orderedmap 2.0.0 to avoid around a TypeScript compilation issue. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de4b643..8120235 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "url": "git://github.com/prosemirror/prosemirror-model.git" }, "dependencies": { - "orderedmap": "^1.1.0" + "orderedmap": "^2.0.0" }, "devDependencies": { "@prosemirror/buildhelper": "^0.1.5", From 152ce93aa3d72a66b26b05f533ff36017d4af2ae Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 15 Jun 2022 09:06:14 +0200 Subject: [PATCH 059/112] Mark version 1.18.1 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2479a0..026c87c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.18.1 (2022-06-15) + +### Bug fixes + +Upgrade to orderedmap 2.0.0 to avoid around a TypeScript compilation issue. + ## 1.18.0 (2022-06-07) ### New features diff --git a/package.json b/package.json index 8120235..bf1d153 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.18.0", + "version": "1.18.1", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 8df218e3e46811503c3dcd6326e147dad0a727d7 Mon Sep 17 00:00:00 2001 From: Daniel <16339876+dreusel@users.noreply.github.com> Date: Wed, 13 Jul 2022 19:30:26 +0200 Subject: [PATCH 060/112] Make it compile in typescript 3 --- src/to_dom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/to_dom.ts b/src/to_dom.ts index a386888..419590b 100644 --- a/src/to_dom.ts +++ b/src/to_dom.ts @@ -20,7 +20,7 @@ import {DOMNode} from "./dom" /// 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} | [string, ...any] +export type DOMOutputSpec = string | DOMNode | {dom: DOMNode, contentDOM?: HTMLElement} | [string, ...any[]] /// A DOM serializer knows how to convert ProseMirror nodes and /// marks of various types to DOM nodes. From b99fa7246727a3a6a0c39896e6f2b702c650ea7b Mon Sep 17 00:00:00 2001 From: ocavue Date: Wed, 13 Jul 2022 16:15:59 +0800 Subject: [PATCH 061/112] Add NodeType.checkContent method This method will throw a RangeError with some debugging information if the content is not valid. --- src/node.ts | 3 +-- src/replace.ts | 3 +-- src/schema.ts | 11 +++++++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/node.ts b/src/node.ts index aec9c19..db67da2 100644 --- a/src/node.ts +++ b/src/node.ts @@ -297,8 +297,7 @@ export class Node { /// Check whether this node and its descendants conform to the /// schema, and raise error when they do not. check() { - if (!this.type.validContent(this.content)) - throw new RangeError(`Invalid content for node ${this.type.name}: ${this.content.toString().slice(0, 50)}`) + 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)) diff --git a/src/replace.ts b/src/replace.ts index 519116f..9aa30a6 100644 --- a/src/replace.ts +++ b/src/replace.ts @@ -180,8 +180,7 @@ function addRange($start: ResolvedPos | null, $end: ResolvedPos | null, depth: n } function close(node: Node, content: Fragment) { - if (!node.type.validContent(content)) - throw new ReplaceError("Invalid content for node " + node.type.name) + node.type.checkContent(content) return node.copy(content) } diff --git a/src/schema.ts b/src/schema.ts index 9a08eae..84b5b73 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -144,8 +144,7 @@ export class NodeType { /// if it doesn't match. createChecked(attrs: Attrs | null = null, content?: Fragment | Node | readonly Node[] | null, marks?: readonly Mark[]) { content = Fragment.from(content) - if (!this.validContent(content)) - throw new RangeError("Invalid content for node " + this.name) + this.checkContent(content) return new Node(this, this.computeAttrs(attrs), content, Mark.setFrom(marks)) } @@ -179,6 +178,14 @@ export class NodeType { 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 From cdb4819e257d72d3faf896c23aa74c9ff66fb968 Mon Sep 17 00:00:00 2001 From: lastnigtic Date: Wed, 27 Jul 2022 18:12:52 +0800 Subject: [PATCH 062/112] Change the array type in DOMOutputSpec to readonly --- src/to_dom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/to_dom.ts b/src/to_dom.ts index 419590b..76188b8 100644 --- a/src/to_dom.ts +++ b/src/to_dom.ts @@ -20,7 +20,7 @@ import {DOMNode} from "./dom" /// 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} | [string, ...any[]] +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. From 777079ad54cc09620fab4048c9585966145e8966 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 11 Aug 2022 11:42:54 +0200 Subject: [PATCH 063/112] Suppress type errors --- src/to_dom.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/to_dom.ts b/src/to_dom.ts index 76188b8..20bfea2 100644 --- a/src/to_dom.ts +++ b/src/to_dom.ts @@ -137,10 +137,10 @@ export class DOMSerializer { else dom.setAttribute(name, attrs[name]) } } - for (let i = start; i < (structure as any[]).length; i++) { + 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 any[]).length - 1 || i > start) + 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 { From 5e6c84d42a2d937a2fa9c5d3e22ceecd3a2bacb8 Mon Sep 17 00:00:00 2001 From: Simon Chester Date: Wed, 17 Aug 2022 11:07:30 +1200 Subject: [PATCH 064/112] Ensure Node.inlineContent returns a boolean Previously, it could also return 0 --- src/content.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content.ts b/src/content.ts index e5a4255..df82c11 100644 --- a/src/content.ts +++ b/src/content.ts @@ -49,7 +49,7 @@ export class ContentMatch { /// @internal get inlineContent() { - return this.next.length && this.next[0].type.isInline + return this.next.length != 0 && this.next[0].type.isInline } /// Get the first matching node type at this match position that can From cc6c4755982db8da7cd75d11dac14c8e5b9dbd26 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 9 Oct 2022 11:28:21 +0200 Subject: [PATCH 065/112] Remove gitter link Discussion there works poorly, and it's started to attract spam. Use the forum instead. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 50c4d3c..c78c668 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # prosemirror-model -[ [**WEBSITE**](https://prosemirror.net) | [**ISSUES**](https://github.com/prosemirror/prosemirror/issues) | [**FORUM**](https://discuss.prosemirror.net) | [**GITTER**](https://gitter.im/ProseMirror/prosemirror) | [**CHANGELOG**](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md) ] +[ [**WEBSITE**](https://prosemirror.net) | [**ISSUES**](https://github.com/prosemirror/prosemirror/issues) | [**FORUM**](https://discuss.prosemirror.net) | [**CHANGELOG**](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md) ] This is a [core module](https://prosemirror.net/docs/ref/#model) of [ProseMirror](https://prosemirror.net). ProseMirror is a well-behaved rich semantic content editor based on From dacd4cb9a40b52399c9fe6bd0e637d4a7b201ae1 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 14 Nov 2022 14:19:51 +0100 Subject: [PATCH 066/112] Close blocks with inline content on seeing block-level elements FIX: Improve DOM parsing of nested block elements mixing block and inline children. Closes https://github.com/ProseMirror/prosemirror/issues/1332 --- src/from_dom.ts | 4 ++++ test/test-dom.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/from_dom.ts b/src/from_dom.ts index 7e771e6..7841c6e 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -478,6 +478,10 @@ class ParseContext { 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) { diff --git a/test/test-dom.ts b/test/test-dom.ts index 604c447..dba6a2b 100644 --- a/test/test-dom.ts +++ b/test/test-dom.ts @@ -273,6 +273,10 @@ describe("DOMParser", () => { it("doesn't ignore whitespace-only nodes in preserveWhitespace full mode", recover(" x", doc(p(" x")), {preserveWhitespace: "full"})) + it("closes block with inline content on seeing block-level children", + recover("

    CCC
    DDD

    ", + doc(p(br()), p("CCC"), p("DDD"), p(br())))) + function parse(html: string, options: ParseOptions, doc: PMNode) { return () => { let dom = document.createElement("div") From 1b843bf5ed060fa72a75f9cdb3af615fc9350681 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 14 Nov 2022 14:20:18 +0100 Subject: [PATCH 067/112] Mark version 1.18.2 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 026c87c..bd6e5d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.18.2 (2022-11-14) + +### Bug fixes + +Improve DOM parsing of nested block elements mixing block and inline children. + ## 1.18.1 (2022-06-15) ### Bug fixes diff --git a/package.json b/package.json index bf1d153..595c5a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.18.1", + "version": "1.18.2", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 35e4fd6d00f60e5810633645ae6d7b980a988853 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 16 Nov 2022 15:03:56 +0100 Subject: [PATCH 068/112] Copy all spec properties to Schema.spec FIX: Copy all properties from the input spec to `Schema.spec`. See https://discuss.prosemirror.net/t/attaching-custom-props-to-schemas-spec/5036 --- src/schema.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 84b5b73..d4e9e19 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -532,11 +532,10 @@ export class Schema { /// Construct a schema from a schema [specification](#model.SchemaSpec). constructor(spec: SchemaSpec) { - this.spec = { - nodes: OrderedMap.from(spec.nodes), - marks: OrderedMap.from(spec.marks || {}), - topNode: spec.topNode - } + 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) From 0395aae881400b80045c7004fd21cfe0921ea34a Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 18 Nov 2022 17:16:06 +0100 Subject: [PATCH 069/112] Mark version 1.18.3 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6e5d2..57e33b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.18.3 (2022-11-18) + +### Bug fixes + +Copy all properties from the input spec to `Schema.spec`. + ## 1.18.2 (2022-11-14) ### Bug fixes diff --git a/package.json b/package.json index 595c5a2..878cdea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.18.2", + "version": "1.18.3", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From be4ae4e6e744542020574965444a525968618307 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 18 Jan 2023 11:06:29 +0100 Subject: [PATCH 070/112] Allow style parse rules to clear marks FEATURE: Parse rules for styles can now provide a `clearMark` property to remove pending marks (for example for `font-style: normal`). Issue https://github.com/ProseMirror/prosemirror/issues/1347 --- src/from_dom.ts | 47 ++++++++++++++++++++++++++++++++++------------- test/test-dom.ts | 4 ++++ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/from_dom.ts b/src/from_dom.ts index 7841c6e..ac02bbc 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -100,15 +100,20 @@ export interface ParseRule { /// The name of the node type to create when this rule matches. Only /// valid for rules with a `tag` property, not for style rules. Each - /// rule should have one of 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). + /// rule should have one of a `node`, `mark`, `clearMark`, 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 /// The name of the mark type to wrap the matched content in. mark?: string + /// [Style](#model.ParseRule.style) rules can remove marks from the + /// set of active marks. + clearMark?: (mark: Mark) => boolean + /// When true, ignore content that matches this rule. ignore?: boolean @@ -261,14 +266,16 @@ export class DOMParser { let rules = schema.marks[name].spec.parseDOM if (rules) rules.forEach(rule => { insert(rule = copy(rule)) - rule.mark = name + if (!(rule.mark || rule.ignore || rule.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)) - rule.node = name + if (!(rule.node || rule.ignore || rule.mark)) + rule.node = name }) } return result @@ -425,10 +432,18 @@ class ParseContext { this.addTextNode(dom as Text) } else if (dom.nodeType == 1) { let style = (dom as HTMLElement).getAttribute("style") - let marks = style ? this.readStyles(parseStyles(style)) : null, top = this.top - if (marks != null) for (let i = 0; i < marks.length; i++) this.addPendingMark(marks[i]) - this.addElement(dom as HTMLElement) - if (marks != null) for (let i = 0; i < marks.length; i++) this.removePendingMark(marks[i], top) + if (!style) { + this.addElement(dom as HTMLElement) + } else { + 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]) + this.addElement(dom as HTMLElement) + 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]) + } } } @@ -513,18 +528,24 @@ class ParseContext { // return an array of marks, or null to indicate some of the styles // had a rule with `ignore` set. readStyles(styles: readonly string[]) { - let marks = Mark.none + let add = Mark.none, remove = Mark.none style: 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) continue style if (rule.ignore) return null - marks = this.parser.schema.marks[rule.mark!].create(rule.attrs).addToSet(marks) + if (rule.clearMark) { + this.top.pendingMarks.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 marks + return [add, remove] } // Look up a handler for the given node. If none are found, return diff --git a/test/test-dom.ts b/test/test-dom.ts index dba6a2b..3950556 100644 --- a/test/test-dom.ts +++ b/test/test-dom.ts @@ -258,6 +258,10 @@ describe("DOMParser", () => { recover("

    Hello

    ", doc(p(strong("Hello"))))) + it("allows clearing of styles", + recover("

    One

    Two

    abc

    ", doc(p("abc")))) From 93e9a4053a86a9ccc18ef83d30cef20b45e7365b Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 18 Jan 2023 11:06:42 +0100 Subject: [PATCH 071/112] Mark version 1.19.0 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e33b4..ac6a6ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.19.0 (2023-01-18) + +### New features + +Parse rules for styles can now provide a `clearMark` property to remove pending marks (for example for `font-style: normal`). + ## 1.18.3 (2022-11-18) ### Bug fixes diff --git a/package.json b/package.json index 878cdea..da896e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.18.3", + "version": "1.19.0", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 018df43aedeb18f9e7f52576d958d0eaf4f86258 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 18 Jan 2023 22:10:35 +0100 Subject: [PATCH 072/112] =?UTF-8?q?Remove=20a=20labeled=20loop=20that=20br?= =?UTF-8?q?eaks=20Bubl=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/from_dom.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/from_dom.ts b/src/from_dom.ts index ac02bbc..b6e3cd2 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -529,10 +529,10 @@ class ParseContext { // had a rule with `ignore` set. readStyles(styles: readonly string[]) { let add = Mark.none, remove = Mark.none - style: for (let i = 0; i < styles.length; i += 2) { + 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) continue style + if (!rule) break if (rule.ignore) return null if (rule.clearMark) { this.top.pendingMarks.forEach(m => { From e8778ded0cdedb3b6ea1c8a1da18a90f16ec5eb2 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 24 Jan 2023 08:24:18 +0100 Subject: [PATCH 073/112] Update maintainer email --- LICENSE | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index ef9326c..7e2295b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (C) 2015-2017 by Marijn Haverbeke and others +Copyright (C) 2015-2017 by Marijn Haverbeke and others Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/package.json b/package.json index da896e4..2a8627e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "maintainers": [ { "name": "Marijn Haverbeke", - "email": "marijnh@gmail.com", + "email": "marijn@haverbeke.berlin", "web": "http://marijnhaverbeke.nl" } ], From 9201015c268947c34fa31be26b8b7aa5a0cf9776 Mon Sep 17 00:00:00 2001 From: Tom Locke Date: Tue, 31 Jan 2023 15:46:30 +0000 Subject: [PATCH 074/112] Small clarification of docs of Node.nodesBetween --- src/node.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/node.ts b/src/node.ts index db67da2..6257bcf 100644 --- a/src/node.ts +++ b/src/node.ts @@ -67,10 +67,11 @@ export class Node { /// 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 - /// parent-relative position, 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. + /// 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) { From 1cbef0f8762c0e6030555ee3f5dab81785efd7e5 Mon Sep 17 00:00:00 2001 From: Will Hawker Date: Tue, 21 Mar 2023 12:20:52 +0000 Subject: [PATCH 075/112] Expose the index parameter in the Fragment descendants callback FIX: Fix the types of `Fragment.desendants` to include the index parameter to the callback. --- src/fragment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fragment.ts b/src/fragment.ts index 731e606..874846a 100644 --- a/src/fragment.ts +++ b/src/fragment.ts @@ -45,7 +45,7 @@ export class Fragment { /// 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) => boolean | void) { + descendants(f: (node: Node, pos: number, parent: Node | null, index: number) => boolean | void) { this.nodesBetween(0, this.size, f) } From 8ff6941ecacd47b189b1e7c3b33cbb716291f2c8 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 17 May 2023 15:36:15 +0200 Subject: [PATCH 076/112] Add release note FIX: Include CommonJS type declarations in the package to please new TypeScript resolution settings. From e486c6f8843d903c7196572a730ba884e8ac98d7 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 17 May 2023 15:37:45 +0200 Subject: [PATCH 077/112] Mark version 1.19.1 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6a6ba..5742d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 1.19.1 (2023-05-17) + +### Bug fixes + +Fix the types of `Fragment.desendants` to include the index parameter to the callback. Add release note + +Include CommonJS type declarations in the package to please new TypeScript resolution settings. + ## 1.19.0 (2023-01-18) ### New features diff --git a/package.json b/package.json index 2a8627e..6997b2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.19.0", + "version": "1.19.1", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 4ba155f1342822bc9ab696468ea26f4671d79d48 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 19 May 2023 11:11:53 +0200 Subject: [PATCH 078/112] Allow active marks to be cleared by clearMark parse rules FIX: Allow parse rules with a `clearMark` directive to clear marks that have already been applied. Closes https://github.com/ProseMirror/prosemirror/issues/1376 --- src/from_dom.ts | 2 +- test/test-dom.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/from_dom.ts b/src/from_dom.ts index b6e3cd2..bce67bc 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -535,7 +535,7 @@ class ParseContext { if (!rule) break if (rule.ignore) return null if (rule.clearMark) { - this.top.pendingMarks.forEach(m => { + this.top.pendingMarks.concat(this.top.activeMarks).forEach(m => { if (rule!.clearMark!(m)) remove = m.addToSet(remove) }) } else { diff --git a/test/test-dom.ts b/test/test-dom.ts index 3950556..9e18d7f 100644 --- a/test/test-dom.ts +++ b/test/test-dom.ts @@ -258,10 +258,15 @@ describe("DOMParser", () => { recover("

    Hello

    ", doc(p(strong("Hello"))))) - it("allows clearing of styles", + it("allows clearing of pending marks", recover("

    One

    Two

  • Foo" + + "Bar

  • ", + doc(ul(li(p(em("Foo"), "Bar")))))) + it("ignores unknown inline tags", recover("

    abc

    ", doc(p("abc")))) From 5a5aebad793f0b9fc07d5a5acc52d186b66e22d8 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 23 May 2023 10:58:15 +0200 Subject: [PATCH 079/112] Mark version 1.19.2 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5742d96..ae3d32a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.19.2 (2023-05-23) + +### Bug fixes + +Allow parse rules with a `clearMark` directive to clear marks that have already been applied. + ## 1.19.1 (2023-05-17) ### Bug fixes diff --git a/package.json b/package.json index 6997b2c..821d413 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.19.1", + "version": "1.19.2", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 4947c38ef0e76b3395acad58113f53dd81c7078a Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 13 Jul 2023 11:49:56 +0200 Subject: [PATCH 080/112] Don't apply style parse rules for skipped nodes FIX: Don't apply style parse rules for nodes that are skipped by other parse rules. Closes https://github.com/ProseMirror/prosemirror/issues/1398 --- src/from_dom.ts | 41 +++++++++++++++++++++-------------------- test/test-dom.ts | 11 +++++++++++ 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/from_dom.ts b/src/from_dom.ts index bce67bc..fba9639 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -428,23 +428,21 @@ class ParseContext { // 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) { - let style = (dom as HTMLElement).getAttribute("style") - if (!style) { - this.addElement(dom as HTMLElement) - } else { - 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]) - this.addElement(dom as HTMLElement) - 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]) - } - } + 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) { @@ -481,7 +479,7 @@ class ParseContext { // 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?: ParseRule) { - let name = dom.nodeName.toLowerCase(), ruleID + let name = dom.nodeName.toLowerCase(), ruleID: ParseRule | 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)) @@ -503,11 +501,14 @@ class ParseContext { this.leafFallback(dom) return } - this.addAll(dom) + if (rule && rule.skip) this.addAll(dom) + else this.withStyleRules(dom, () => this.addAll(dom)) if (sync) this.sync(top) this.needsBlock = oldNeedsBlock } else { - this.addElementByRule(dom, rule, rule.consuming === false ? ruleID : undefined) + this.withStyleRules(dom, () => { + this.addElementByRule(dom, rule!, rule!.consuming === false ? ruleID : undefined) + }) } } diff --git a/test/test-dom.ts b/test/test-dom.ts index 9e18d7f..8cc0e8e 100644 --- a/test/test-dom.ts +++ b/test/test-dom.ts @@ -532,6 +532,17 @@ describe("DOMParser", () => { it("doesn't get confused by nested mark tags", recover("
    AB
    C", doc(p(strong("A"), "B"), p("C")))) + + it("ignores styles on skipped nodes", () => { + let dom = document.createElement("div") + dom.innerHTML = "

    abc def

    " + ist(parser.parse(dom, { + ruleFromNode: node => { + return node.nodeType == 1 && (node as HTMLElement).tagName == "SPAN" ? {skip: node as any} : null + } + }), doc(p("abc def")), eq) + + }) }) describe("schemaRules", () => { From 387576a887335b5dccba4c940b7935e1da13ef73 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 13 Jul 2023 11:50:01 +0200 Subject: [PATCH 081/112] Mark version 1.19.3 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3d32a..54e091f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.19.3 (2023-07-13) + +### Bug fixes + +Don't apply style parse rules for nodes that are skipped by other parse rules. + ## 1.19.2 (2023-05-23) ### Bug fixes diff --git a/package.json b/package.json index 821d413..1f994d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.19.2", + "version": "1.19.3", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 49db562e89b81984c3b2b9e13d5756b18ef1927c Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 8 Oct 2023 13:01:40 +0200 Subject: [PATCH 082/112] Make sure textBetween includes block separators around empty textblocks FIX: Make `textBetween` emit block separators for empty textblocks. Closes https://github.com/ProseMirror/prosemirror/issues/1419 --- src/fragment.ts | 24 ++++++++++-------------- test/test-node.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/fragment.ts b/src/fragment.ts index 874846a..5757046 100644 --- a/src/fragment.ts +++ b/src/fragment.ts @@ -52,22 +52,18 @@ export class Fragment { /// 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 = "", separated = true + let text = "", first = true this.nodesBetween(from, to, (node, pos) => { - if (node.isText) { - text += node.text!.slice(Math.max(from, pos) - pos, to - pos) - separated = !blockSeparator - } else if (node.isLeaf) { - if (leafText) { - text += typeof leafText === "function" ? leafText(node) : leafText; - } else if (node.type.spec.leafText) { - text += node.type.spec.leafText(node); - } - separated = !blockSeparator; - } else if (!separated && node.isBlock) { - text += blockSeparator - separated = true + 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 } diff --git a/test/test-node.ts b/test/test-node.ts index 86ef863..cd01593 100644 --- a/test/test-node.ts +++ b/test/test-node.ts @@ -120,6 +120,18 @@ describe("Node", () => { ]) ist(d.textBetween(0, d.content.size, '', ''), 'Hello ') }) + + it("adds block separator around empty paragraphs", () => { + ist(doc(p("one"), p(), p("two")).textBetween(0, 12, "\n"), "one\n\ntwo") + }) + + it("adds block separator around leaf nodes", () => { + ist(doc(p("one"), hr(), hr(), p("two")).textBetween(0, 12, "\n", "---"), "one\n---\n---\ntwo") + }) + + it("doesn't add block separator around non-rendered leaf nodes", () => { + ist(doc(p("one"), hr(), hr(), p("two")).textBetween(0, 12, "\n"), "one\ntwo") + }) }) describe("textContent", () => { From 1b2ebc7a0c1fa7e6dfe426aaaf97d184ab5d3b54 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 11 Dec 2023 12:44:56 +0100 Subject: [PATCH 083/112] Upgrade jsdom for tests --- package.json | 2 +- test/test-dom.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1f994d0..e50d590 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "devDependencies": { "@prosemirror/buildhelper": "^0.1.5", - "jsdom": "^10.1.0", + "jsdom": "^20.0.0", "prosemirror-test-builder": "^1.0.0" }, "scripts": { diff --git a/test/test-dom.ts b/test/test-dom.ts index 8cc0e8e..45cd1d8 100644 --- a/test/test-dom.ts +++ b/test/test-dom.ts @@ -153,7 +153,7 @@ describe("DOMParser", () => { let b = builders(xmlnsSchema) as any let d = b.doc(b.svg()) - test(d, "", xmlDocument)() + test(d, '', xmlDocument)() let dom = xmlDocument.createElement('div') dom.appendChild(DOMSerializer.fromSchema(xmlnsSchema).serializeFragment(d.content, {document: xmlDocument})) From a37b6b3adeb548dc9822211b680ce9d31be65842 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 11 Dec 2023 12:45:08 +0100 Subject: [PATCH 084/112] Mark version 1.19.4 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e091f..51d38bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.19.4 (2023-12-11) + +### Bug fixes + +Make `textBetween` emit block separators for empty textblocks. + ## 1.19.3 (2023-07-13) ### Bug fixes diff --git a/package.json b/package.json index e50d590..9f38eb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.19.3", + "version": "1.19.4", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 79a38068daa4a4f6e9c0d5d32e77a0e54ab29dae Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 7 Apr 2024 11:03:35 +0200 Subject: [PATCH 085/112] Make ParseRule a union type FEATURE: The `ParseRule` type is now a union of `TagParseRule` and `StyleParseRule`, with more specific types being used when appropriate. --- src/README.md | 3 ++ src/from_dom.ts | 134 ++++++++++++++++++++++++++--------------------- src/index.ts | 2 +- src/schema.ts | 4 +- test/test-dom.ts | 6 +-- 5 files changed, 82 insertions(+), 67 deletions(-) diff --git a/src/README.md b/src/README.md index 3aba9c8..ddfbd28 100644 --- a/src/README.md +++ b/src/README.md @@ -53,6 +53,9 @@ to use this module.) @DOMParser @ParseOptions +@GenericParseRule +@TagParseRule +@StyleParseRule @ParseRule @DOMSerializer diff --git a/src/from_dom.ts b/src/from_dom.ts index fba9639..3484ed1 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -45,33 +45,14 @@ export interface ParseOptions { context?: ResolvedPos /// @internal - ruleFromNode?: (node: DOMNode) => ParseRule | null + ruleFromNode?: (node: DOMNode) => Omit | null /// @internal topOpen?: boolean } -/// A value that describes how to parse a given DOM node or inline -/// style as a ProseMirror node or mark. -export interface ParseRule { - /// A CSS selector describing the kind of DOM elements to match. A - /// single rule should have _either_ a `tag` or a `style` property. - tag?: string - - /// The namespace to match. This should be used with `tag`. - /// Nodes are only matched when the namespace matches or this property - /// is null. - namespace?: string - - /// A CSS property name to match. When given, this rule matches - /// 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 - +/// 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 @@ -98,22 +79,9 @@ export interface ParseRule { /// character, as in `"blockquote/|list_item/"`. context?: string - /// The name of the node type to create when this rule matches. Only - /// valid for rules with a `tag` property, not for style rules. Each - /// rule should have one of a `node`, `mark`, `clearMark`, 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 - /// The name of the mark type to wrap the matched content in. mark?: string - /// [Style](#model.ParseRule.style) rules can remove marks from the - /// set of active marks. - clearMark?: (mark: Mark) => boolean - /// When true, ignore content that matches this rule. ignore?: boolean @@ -128,23 +96,37 @@ export interface ParseRule { /// 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. - /// - /// Called with a DOM Element for `tag` rules, and with a string (the - /// style's value) for `style` rules. - getAttrs?: (node: HTMLElement | string) => Attrs | false | null - - /// For `tag` rules that produce non-leaf nodes or marks, by default - /// the content of the DOM element is parsed as content of the mark - /// or 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. + 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 @@ -160,14 +142,44 @@ export interface ParseRule { 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: ParseRule[] = [] + tags: TagParseRule[] = [] /// @internal - styles: ParseRule[] = [] + styles: StyleParseRule[] = [] /// @internal normalizeLists: boolean @@ -181,8 +193,8 @@ export class DOMParser { readonly rules: readonly ParseRule[] ) { rules.forEach(rule => { - if (rule.tag) this.tags.push(rule) - else if (rule.style) this.styles.push(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 @@ -213,7 +225,7 @@ export class DOMParser { } /// @internal - matchTag(dom: DOMNode, context: ParseContext, after?: ParseRule) { + 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!) && @@ -230,7 +242,7 @@ export class DOMParser { } /// @internal - matchStyle(prop: string, value: string, context: ParseContext, after?: ParseRule) { + 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 || @@ -265,16 +277,16 @@ export class DOMParser { for (let name in schema.marks) { let rules = schema.marks[name].spec.parseDOM if (rules) rules.forEach(rule => { - insert(rule = copy(rule)) - if (!(rule.mark || rule.ignore || rule.clearMark)) + 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)) - if (!(rule.node || rule.ignore || rule.mark)) + insert(rule = copy(rule) as TagParseRule) + if (!((rule as TagParseRule).node || rule.ignore || rule.mark)) rule.node = name }) } @@ -478,8 +490,8 @@ class ParseContext { // 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?: ParseRule) { - let name = dom.nodeName.toLowerCase(), ruleID: ParseRule | undefined + 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)) @@ -507,7 +519,7 @@ class ParseContext { this.needsBlock = oldNeedsBlock } else { this.withStyleRules(dom, () => { - this.addElementByRule(dom, rule!, rule!.consuming === false ? ruleID : undefined) + this.addElementByRule(dom, rule as TagParseRule, rule!.consuming === false ? ruleID : undefined) }) } } @@ -552,7 +564,7 @@ class ParseContext { // 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: ParseRule, continueAfter?: ParseRule) { + addElementByRule(dom: HTMLElement, rule: TagParseRule, continueAfter?: TagParseRule) { let sync, nodeType, mark if (rule.node) { nodeType = this.parser.schema.nodes[rule.node] diff --git a/src/index.ts b/src/index.ts index 4c449de..e131cae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,5 +7,5 @@ export {Mark} from "./mark" export {Schema, NodeType, Attrs, MarkType, NodeSpec, MarkSpec, AttributeSpec, SchemaSpec} from "./schema" export {ContentMatch} from "./content" -export {DOMParser, ParseRule, ParseOptions} from "./from_dom" +export {DOMParser, GenericParseRule, TagParseRule, StyleParseRule, ParseRule, ParseOptions} from "./from_dom" export {DOMSerializer, DOMOutputSpec} from "./to_dom" diff --git a/src/schema.ts b/src/schema.ts index d4e9e19..820bba2 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5,7 +5,7 @@ import {Fragment} from "./fragment" import {Mark} from "./mark" import {ContentMatch} from "./content" import {DOMOutputSpec} from "./to_dom" -import {ParseRule} from "./from_dom" +import {ParseRule, TagParseRule} from "./from_dom" /// An object holding the attributes of a node. export type Attrs = {readonly [attr: string]: any} @@ -429,7 +429,7 @@ export interface NodeSpec { /// 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 ParseRule[] + 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). diff --git a/test/test-dom.ts b/test/test-dom.ts index 45cd1d8..7d5943c 100644 --- a/test/test-dom.ts +++ b/test/test-dom.ts @@ -107,7 +107,7 @@ describe("DOMParser", () => { attrs: { id: { default: null }}, parseDOM: [{ tag: "span.comment", - getAttrs(dom) { return { id: parseInt((dom as HTMLElement).getAttribute('data-id')!, 10) } } + getAttrs(dom: HTMLElement) { return { id: parseInt(dom.getAttribute('data-id')!, 10) } } }], excludes: '', toDOM(mark: Mark) { return ["span", {class: "comment", "data-id": mark.attrs.id }, 0] } @@ -554,7 +554,7 @@ describe("DOMParser", () => { foo: {group: "inline", inline: true, parseDOM: [{tag: "foo"}]}, bar: {group: "inline", inline: true, parseDOM: [{tag: "bar"}]}} }) - ist(DOMParser.schemaRules(schema).map(r => r.tag).join(" "), "i em foo bar") + ist(DOMParser.schemaRules(schema).map(r => (r as any).tag).join(" "), "i em foo bar") }) it("understands priority", () => { @@ -565,7 +565,7 @@ describe("DOMParser", () => { foo: {group: "inline", inline: true, parseDOM: [{tag: "foo"}]}, bar: {group: "inline", inline: true, parseDOM: [{tag: "bar", priority: 60}]}} }) - ist(DOMParser.schemaRules(schema).map(r => r.tag).join(" "), "em bar foo i") + ist(DOMParser.schemaRules(schema).map(r => (r as any).tag).join(" "), "em bar foo i") }) function nsParse(doc: Node, namespace?: string) { From 4ea136e8aed30c3e47b7e2ffb3c9762318e0cb93 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 8 Apr 2024 08:37:38 +0200 Subject: [PATCH 086/112] Mark version 1.20.0 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d38bf..21951f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.20.0 (2024-04-08) + +### New features + +The `ParseRule` type is now a union of `TagParseRule` and `StyleParseRule`, with more specific types being used when appropriate. + ## 1.19.4 (2023-12-11) ### Bug fixes diff --git a/package.json b/package.json index 9f38eb2..6a713ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.19.4", + "version": "1.20.0", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From a8a8041027d1b941affd83bb02589526a259b7d7 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 6 May 2024 14:48:52 +0200 Subject: [PATCH 087/112] Add support for linebreak replacement nodes in schema configuration FEATURE: The new `linebreakReplacement` property on node specs makes it possible to configure a node type that `setBlockType` will convert to and from line breaks when appropriate. Issue https://github.com/ProseMirror/prosemirror/issues/1460 --- src/schema.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/schema.ts b/src/schema.ts index 820bba2..aeb0a6a 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -441,6 +441,15 @@ export interface NodeSpec { /// [`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 @@ -530,6 +539,11 @@ export class Schema { /// 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) { let instanceSpec = this.spec = {} as any @@ -548,6 +562,11 @@ export class Schema { 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 From b71f73f193b15ab1661451636352905b06a6fb0d Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 6 May 2024 14:50:47 +0200 Subject: [PATCH 088/112] Mark version 1.21.0 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21951f0..210a053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.21.0 (2024-05-06) + +### New features + +The new `linebreakReplacement` property on node specs makes it possible to configure a node type that `setBlockType` will convert to and from line breaks when appropriate. + ## 1.20.0 (2024-04-08) ### New features diff --git a/package.json b/package.json index 6a713ad..b432914 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.20.0", + "version": "1.21.0", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From d61616994c1907f6856aa2cf027a0e4944fc8023 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 20 May 2024 23:57:37 +1000 Subject: [PATCH 089/112] Make some internal types more precise --- src/schema.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index aeb0a6a..8c73739 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -14,7 +14,7 @@ export type Attrs = {readonly [attr: string]: any} // 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) { +function defaultAttrs(attrs: {[name: string]: Attribute}) { let defaults = Object.create(null) for (let attrName in attrs) { let attr = attrs[attrName] @@ -24,7 +24,7 @@ function defaultAttrs(attrs: Attrs) { return defaults } -function computeAttrs(attrs: Attrs, value: Attrs | null) { +function computeAttrs(attrs: {[name: string]: Attribute}, value: Attrs | null) { let built = Object.create(null) for (let name in attrs) { let given = value && value[name] @@ -643,7 +643,7 @@ export class Schema { } function gatherMarks(schema: Schema, marks: readonly string[]) { - let found = [] + let found: MarkType[] = [] for (let i = 0; i < marks.length; i++) { let name = marks[i], mark = schema.marks[name], ok = mark if (mark) { From 7726c3bb8e5e83b562e5c66d7e2a9c5515d60869 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 20 May 2024 12:26:18 +0200 Subject: [PATCH 090/112] Clean up outdated doc comment --- src/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index 8c73739..e73e2e5 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -169,7 +169,7 @@ export class NodeType { } /// Returns true if the given fragment is valid content for this node - /// type with the given attributes. + /// type. validContent(content: Fragment) { let result = this.contentMatch.matchFragment(content) if (!result || !result.validEnd) return false From 899a98e62db1b2262bb4c67e4a401bc39b530385 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 2 Jun 2024 21:59:42 +0200 Subject: [PATCH 091/112] Use the parsed DOM styles instead of parsing them separately FIX: Improve performance and accuracy of `DOMParser` style matching by using the DOM's own `style` object. Issue https://github.com/ProseMirror/prosemirror/issues/1470 --- src/from_dom.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/from_dom.ts b/src/from_dom.ts index 3484ed1..82d4e66 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -445,9 +445,9 @@ class ParseContext { } withStyleRules(dom: HTMLElement, f: () => void) { - let style = dom.getAttribute("style") - if (!style) return f() - let marks = this.readStyles(parseStyles(style)) + let style = dom.style + if (!style || !style.length) return f() + let marks = this.readStyles(dom.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) @@ -540,11 +540,12 @@ class ParseContext { // 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[]) { + readStyles(styles: CSSStyleDeclaration) { let add = Mark.none, remove = Mark.none - for (let i = 0; i < styles.length; i += 2) { + for (let i = 0, l = styles.length; i < l; i++) { + let name = styles.item(i) for (let after = undefined;;) { - let rule = this.parser.matchStyle(styles[i], styles[i + 1], this, after) + let rule = this.parser.matchStyle(name, styles[name as any], this, after) if (!rule) break if (rule.ignore) return null if (rule.clearMark) { @@ -831,13 +832,6 @@ 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] From c6bbbbdc9735b0cc21abfd4375245b481ddb9196 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 3 Jun 2024 16:45:02 +0200 Subject: [PATCH 092/112] Use getPropertyValue to fetch style values Issue https://github.com/ProseMirror/prosemirror/issues/1470 --- src/from_dom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/from_dom.ts b/src/from_dom.ts index 82d4e66..afb53b9 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -545,7 +545,7 @@ class ParseContext { for (let i = 0, l = styles.length; i < l; i++) { let name = styles.item(i) for (let after = undefined;;) { - let rule = this.parser.matchStyle(name, styles[name as any], this, after) + let rule = this.parser.matchStyle(name, styles.getPropertyValue(name), this, after) if (!rule) break if (rule.ignore) return null if (rule.clearMark) { From a6c5ce17bffd82a6ca541caa2d9c5363e202bb09 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Mon, 3 Jun 2024 18:24:43 +0200 Subject: [PATCH 093/112] Mark version 1.21.1 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 210a053..1560d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.21.1 (2024-06-03) + +### Bug fixes + +Improve performance and accuracy of `DOMParser` style matching by using the DOM's own `style` object. + ## 1.21.0 (2024-05-06) ### New features diff --git a/package.json b/package.json index b432914..ac58ba6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.21.0", + "version": "1.21.1", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From d326751964dda0297b54a6849e835d14b47430e3 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 18 Jun 2024 12:30:20 +0200 Subject: [PATCH 094/112] Properly mark Node.findIndex as internal --- src/fragment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fragment.ts b/src/fragment.ts index 5757046..6afcb9b 100644 --- a/src/fragment.ts +++ b/src/fragment.ts @@ -189,7 +189,7 @@ export class Fragment { /// 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.) + /// (overwritten) the next time the function is called. @internal 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) From cde085e345b6815c54af9c386feb73bce3ad41ee Mon Sep 17 00:00:00 2001 From: Liujiawen <491522457@qq.com> Date: Mon, 24 Jun 2024 19:31:14 +0800 Subject: [PATCH 095/112] Add some missing type declarations --- src/content.ts | 8 ++++---- src/fragment.ts | 2 +- src/from_dom.ts | 4 ++-- src/node.ts | 2 +- src/resolvedpos.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/content.ts b/src/content.ts index df82c11..9b13a10 100644 --- a/src/content.ts +++ b/src/content.ts @@ -197,14 +197,14 @@ type Expr = {type: "name", value: NodeType} function parseExpr(stream: TokenStream): Expr { - let exprs = [] + let exprs: Expr[] = [] do { exprs.push(parseExprSeq(stream)) } while (stream.eat("|")) return exprs.length == 1 ? exprs[0] : {type: "choice", exprs} } function parseExprSeq(stream: TokenStream): Expr { - let exprs = [] + let exprs: Expr[] = [] do { exprs.push(parseExprSubscript(stream)) } while (stream.next && stream.next != ")" && stream.next != "|") return exprs.length == 1 ? exprs[0] : {type: "seq", exprs} @@ -246,7 +246,7 @@ function parseExprRange(stream: TokenStream, expr: Expr): Expr { function resolveName(stream: TokenStream, name: string): readonly NodeType[] { let types = stream.nodeTypes, type = types[name] if (type) return [type] - let result = [] + let result: NodeType[] = [] for (let typeName in types) { let type = types[typeName] if (type.groups.indexOf(name) > -1) result.push(type) @@ -401,7 +401,7 @@ function dfa(nfa: Edge[][]): ContentMatch { function checkForDeadEnds(match: ContentMatch, stream: TokenStream) { for (let i = 0, work = [match]; i < work.length; i++) { - let state = work[i], dead = !state.validEnd, nodes = [] + let state = work[i], dead = !state.validEnd, nodes: string[] = [] for (let j = 0; j < state.next.length; j++) { let {type, next} = state.next[j] nodes.push(type.name) diff --git a/src/fragment.ts b/src/fragment.ts index 6afcb9b..a0cd4a8 100644 --- a/src/fragment.ts +++ b/src/fragment.ts @@ -85,7 +85,7 @@ export class Fragment { /// 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 + let result: Node[] = [], 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) { diff --git a/src/from_dom.ts b/src/from_dom.ts index afb53b9..c37ff15 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -544,7 +544,7 @@ class ParseContext { let add = Mark.none, remove = Mark.none for (let i = 0, l = styles.length; i < l; i++) { let name = styles.item(i) - for (let after = undefined;;) { + for (let after: StyleParseRule | undefined = undefined;;) { let rule = this.parser.matchStyle(name, styles.getPropertyValue(name), this, after) if (!rule) break if (rule.ignore) return null @@ -814,7 +814,7 @@ class ParseContext { // 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) { + for (let child = dom.firstChild, prevItem: ChildNode | null = null; child; child = child.nextSibling) { let name = child.nodeType == 1 ? child.nodeName.toLowerCase() : null if (name && listTags.hasOwnProperty(name) && prevItem) { prevItem.appendChild(child) diff --git a/src/node.ts b/src/node.ts index 6257bcf..4617894 100644 --- a/src/node.ts +++ b/src/node.ts @@ -323,7 +323,7 @@ export class Node { /// 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 + let marks: Mark[] | undefined = undefined if (json.marks) { if (!Array.isArray(json.marks)) throw new RangeError("Invalid mark data for Node.fromJSON") marks = json.marks.map(schema.markFromJSON) diff --git a/src/resolvedpos.ts b/src/resolvedpos.ts index 93635db..0734e40 100644 --- a/src/resolvedpos.ts +++ b/src/resolvedpos.ts @@ -217,7 +217,7 @@ export class ResolvedPos { /// @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 path: Array = [] let start = 0, parentOffset = pos for (let node = doc;;) { let {index, offset} = node.content.findIndex(parentOffset) From 54de8c0752572f267cf27d5144f9df1c1987953f Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 25 Jun 2024 12:03:31 +0200 Subject: [PATCH 096/112] Use a WeakMap in the resolved position cache to avoid leaking FIX: Make sure resolved positions (and thus the document and schema hanging off them) don't get kept in the cache when their document can be garbage-collected. Closes https://github.com/ProseMirror/prosemirror-model/pull/81 --- src/resolvedpos.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/resolvedpos.ts b/src/resolvedpos.ts index 0734e40..40e4c8e 100644 --- a/src/resolvedpos.ts +++ b/src/resolvedpos.ts @@ -234,17 +234,27 @@ export class ResolvedPos { /// @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 cache = resolveCache.get(doc) + if (cache) { + for (let i = 0; i < cache.elts.length; i++) { + let elt = cache.elts[i] + if (elt.pos == pos) return elt + } + } else { + resolveCache.set(doc, cache = new ResolveCache) } - let result = resolveCache[resolveCachePos] = ResolvedPos.resolve(doc, pos) - resolveCachePos = (resolveCachePos + 1) % resolveCacheSize + let result = cache.elts[cache.i] = ResolvedPos.resolve(doc, pos) + cache.i = (cache.i + 1) % resolveCacheSize return result } } -let resolveCache: ResolvedPos[] = [], resolveCachePos = 0, resolveCacheSize = 12 +class ResolveCache { + elts: ResolvedPos[] = [] + i = 0 +} + +const resolveCacheSize = 12, resolveCache = new WeakMap() /// Represents a flat range of content, i.e. one that starts and /// ends in the same node. From 68c3cd5b81228fb145efb768d0710afe6a420228 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 25 Jun 2024 12:08:06 +0200 Subject: [PATCH 097/112] Mark version 1.21.2 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1560d6a..aa0bb92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.21.2 (2024-06-25) + +### Bug fixes + +Make sure resolved positions (and thus the document and schema hanging off them) don't get kept in the cache when their document can be garbage-collected. + ## 1.21.1 (2024-06-03) ### Bug fixes diff --git a/package.json b/package.json index ac58ba6..1d8ecf3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.21.1", + "version": "1.21.2", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 1f0c6ed03ca23148087df80d36f6febbb522708b Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 26 Jun 2024 08:54:56 +0200 Subject: [PATCH 098/112] Directly query style props used in parse rules ... rather than iterating style items, which may show only normalized versions of the properties. FIX: Fix an issue where parse rules for CSS properties that were shorthands for a number of more detailed properties weren't matching properly. Closes https://github.com/ProseMirror/prosemirror/issues/1473 --- src/from_dom.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/from_dom.ts b/src/from_dom.ts index c37ff15..6284227 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -181,6 +181,8 @@ export class DOMParser { /// @internal styles: StyleParseRule[] = [] /// @internal + matchedStyles: readonly string[] + /// @internal normalizeLists: boolean /// Create a parser that targets the given schema, using the given @@ -192,9 +194,15 @@ export class DOMParser { /// uses, in order of precedence. readonly rules: readonly ParseRule[] ) { + let matchedStyles: string[] = this.matchedStyles = [] rules.forEach(rule => { - if (isTagRule(rule)) this.tags.push(rule) - else if (isStyleRule(rule)) this.styles.push(rule) + if (isTagRule(rule)) { + this.tags.push(rule) + } else if (isStyleRule(rule)) { + let prop = /[^=]*/.exec(rule.style)![0] + if (matchedStyles.indexOf(prop) < 0) matchedStyles.push(prop) + this.styles.push(rule) + } }) // Only normalize list elements when lists in the schema can't directly contain themselves @@ -542,10 +550,15 @@ class ParseContext { // had a rule with `ignore` set. readStyles(styles: CSSStyleDeclaration) { let add = Mark.none, remove = Mark.none - for (let i = 0, l = styles.length; i < l; i++) { - let name = styles.item(i) - for (let after: StyleParseRule | undefined = undefined;;) { - let rule = this.parser.matchStyle(name, styles.getPropertyValue(name), this, after) + // Because many properties will only show up in 'normalized' form + // in `style.item` (i.e. text-decoration becomes + // text-decoration-line, text-decoration-color, etc), we directly + // query the styles mentioned in our rules instead of iterating + // over the items. + if (styles.length) for (let i = 0; i < this.parser.matchedStyles.length; i++) { + let name = this.parser.matchedStyles[i], value = styles.getPropertyValue(name) + if (value) for (let after: StyleParseRule | undefined = undefined;;) { + let rule = this.parser.matchStyle(name, value, this, after) if (!rule) break if (rule.ignore) return null if (rule.clearMark) { From 751134cc35481fa69ae8f9215ea3653873c8eea1 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 26 Jun 2024 08:55:03 +0200 Subject: [PATCH 099/112] Mark version 1.21.3 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0bb92..7af1933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.21.3 (2024-06-26) + +### Bug fixes + +Fix an issue where parse rules for CSS properties that were shorthands for a number of more detailed properties weren't matching properly. + ## 1.21.2 (2024-06-25) ### Bug fixes diff --git a/package.json b/package.json index 1d8ecf3..38cda6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.21.2", + "version": "1.21.3", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From fca6ef99cc9ccd81a7b57dd2924f733ce83f320d Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 14 Jul 2024 17:50:09 +0200 Subject: [PATCH 100/112] Add attribute value validation FEATURE: Attribute specs now support a `validate` property that can be used to provide a validation function for the attribute, to guard against corrupt JSON input. --- src/mark.ts | 4 +++- src/node.ts | 13 ++++++++++--- src/schema.ts | 38 ++++++++++++++++++++++++++++++++++++++ test/test-node.ts | 22 ++++++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/mark.ts b/src/mark.ts index 82d66ad..7ee3ca8 100644 --- a/src/mark.ts +++ b/src/mark.ts @@ -82,7 +82,9 @@ export class Mark { 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) + let mark = type.create(json.attrs) + type.checkAttrs(mark.attrs) + return mark } /// Test whether two sets of marks are identical. diff --git a/src/node.ts b/src/node.ts index 4617894..26f49f8 100644 --- a/src/node.ts +++ b/src/node.ts @@ -296,11 +296,16 @@ export class Node { } /// Check whether this node and its descendants conform to the - /// schema, and raise error when they do not. + /// schema, and raise an exception when they do not. check() { this.type.checkContent(this.content) + this.type.checkAttrs(this.attrs) let copy = Mark.none - for (let i = 0; i < this.marks.length; i++) copy = this.marks[i].addToSet(copy) + for (let i = 0; i < this.marks.length; i++) { + let mark = this.marks[i] + mark.type.checkAttrs(mark.attrs) + copy = mark.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()) @@ -333,7 +338,9 @@ export class Node { return schema.text(json.text, marks) } let content = Fragment.fromJSON(schema, json.content) - return schema.nodeType(json.type).create(json.attrs, content, marks) + let node = schema.nodeType(json.type).create(json.attrs, content, marks) + node.type.checkAttrs(node.attrs) + return node } } diff --git a/src/schema.ts b/src/schema.ts index e73e2e5..ad8c273 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -38,6 +38,15 @@ function computeAttrs(attrs: {[name: string]: Attribute}, value: Attrs | null) { return built } +export function checkAttrs(attrs: {[name: string]: Attribute}, values: Attrs, type: string, name: string) { + for (let name in values) + if (!(name in attrs)) throw new RangeError(`Unsupported attribute ${name} for ${type} of type ${name}`) + for (let name in attrs) { + let attr = attrs[name] + if (attr.validate) attr.validate(values[name]) + } +} + 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]) @@ -186,6 +195,11 @@ export class NodeType { throw new RangeError(`Invalid content for node ${this.name}: ${content.toString().slice(0, 50)}`) } + /// @internal + checkAttrs(attrs: Attrs) { + checkAttrs(this.attrs, attrs, "node", this.name) + } + /// Check whether the given mark type is allowed in this node. allowsMarkType(markType: MarkType) { return this.markSet == null || this.markSet.indexOf(markType) > -1 @@ -226,15 +240,25 @@ export class NodeType { } } +function validateType(type: string) { + let types = type.split("|") + return (value: any) => { + let name = value === null ? "null" : typeof value + if (types.indexOf(name) < 0) throw new RangeError(`Expected value of type ${types}, got ${name}`) + } +} + // Attribute descriptors class Attribute { hasDefault: boolean default: any + validate: undefined | ((value: any) => void) constructor(options: AttributeSpec) { this.hasDefault = Object.prototype.hasOwnProperty.call(options, "default") this.default = options.default + this.validate = typeof options.validate == "string" ? validateType(options.validate) : options.validate } get isRequired() { @@ -304,6 +328,11 @@ export class MarkType { if (set[i].type == this) return set[i] } + /// @internal + checkAttrs(attrs: Attrs) { + checkAttrs(this.attrs, attrs, "mark", this.name) + } + /// Queries whether a given mark type is /// [excluded](#model.MarkSpec.excludes) by this one. excludes(other: MarkType) { @@ -512,6 +541,15 @@ export interface AttributeSpec { /// provided whenever a node or mark of a type that has them is /// created. default?: any + /// A function or type name used to validate values of this + /// attibute. This will be used when deserializing the attribute + /// from JSON, and when running [`Node.check`](#model.Node.check). + /// When a function, it should raise an exception if the value isn't + /// of the expected type or shape. When a string, it should be a + /// `|`-separated string of primitive types (`"number"`, `"string"`, + /// `"boolean"`, `"null"`, and `"undefined"`), and the library will + /// raise an error when the value is not one of those types. + validate?: string | ((value: any) => void) } /// A document schema. Holds [node](#model.NodeType) and [mark diff --git a/test/test-node.ts b/test/test-node.ts index cd01593..e3c220a 100644 --- a/test/test-node.ts +++ b/test/test-node.ts @@ -149,6 +149,28 @@ describe("Node", () => { }) }) + describe("check", () => { + it("notices invalid content", () => { + ist.throws(() => doc(li("x")).check(), + /Invalid content for node doc/) + }) + + it("notices marks in wrong places", () => { + ist.throws(() => doc(schema.nodes.paragraph.create(null, [], [schema.marks.em.create()])).check(), + /Invalid content for node doc/) + }) + + it("notices incorrect sets of marks", () => { + ist.throws(() => schema.text("a", [schema.marks.em.create(), schema.marks.em.create()]).check(), + /Invalid collection of marks/) + }) + + it("notices wrong attribute types", () => { + ist.throws(() => schema.nodes.image.create({src: true}).check(), + /Expected value of type string, got boolean/) + }) + }) + describe("from", () => { function from(arg: Node | Node[] | Fragment | null, expect: Node) { ist(expect.copy(Fragment.from(arg)), expect, eq) From 1357ec7c48ba341f1a3b9f1cbc476e9878a5ed94 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 14 Jul 2024 17:50:15 +0200 Subject: [PATCH 101/112] Mark version 1.22.0 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af1933..db7efe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.22.0 (2024-07-14) + +### New features + +Attribute specs now support a `validate` property that can be used to provide a validation function for the attribute, to guard against corrupt JSON input. + ## 1.21.3 (2024-06-26) ### Bug fixes diff --git a/package.json b/package.json index 38cda6c..0edf4d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.21.3", + "version": "1.22.0", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 6e977d7e43b6074d73414a7f6429e310e8f15546 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 14 Jul 2024 18:56:11 +0200 Subject: [PATCH 102/112] Add code to actively guard against corrupted-attribute XSS attacks FIX: Add code to `DOMSerializer` that rejects DOM output specs when they originate from attribute values, to protect against XSS attacks that use corrupt attribute input. --- src/to_dom.ts | 122 +++++++++++++++++++++++++++++++---------------- test/test-dom.ts | 9 ++++ 2 files changed, 91 insertions(+), 40 deletions(-) diff --git a/src/to_dom.ts b/src/to_dom.ts index 20bfea2..9be482d 100644 --- a/src/to_dom.ts +++ b/src/to_dom.ts @@ -76,7 +76,7 @@ export class DOMSerializer { /// @internal serializeNodeInner(node: Node, options: {document?: Document}) { let {dom, contentDOM} = - DOMSerializer.renderSpec(doc(options), this.nodes[node.type.name](node)) + renderSpec(doc(options), this.nodes[node.type.name](node), null, node.attrs) if (contentDOM) { if (node.isLeaf) throw new RangeError("Content hole not allowed in a leaf node spec") @@ -105,7 +105,7 @@ export class DOMSerializer { /// @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)) + return toDOM && renderSpec(doc(options), toDOM(mark, inline), null, mark.attrs) } /// Render an [output spec](#model.DOMOutputSpec) to a DOM node. If @@ -115,44 +115,7 @@ export class DOMSerializer { 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} + return renderSpec(doc, structure, xmlNS) } /// Build a serializer using the [`toDOM`](#model.NodeSpec.toDOM) @@ -188,3 +151,82 @@ function gatherToDOM(obj: {[node: string]: NodeType | MarkType}) { function doc(options: {document?: Document}) { return options.document || window.document } + +const suspiciousAttributeCache = new WeakMap() + +function suspiciousAttributes(attrs: {[name: string]: any}): readonly any[] | null { + let value = suspiciousAttributeCache.get(attrs) + if (value === undefined) + suspiciousAttributeCache.set(attrs, value = suspiciousAttributesInner(attrs)) + return value +} + +function suspiciousAttributesInner(attrs: {[name: string]: any}): readonly any[] | null { + let result: any[] | null = null + function scan(value: any) { + if (value && typeof value == "object") { + if (Array.isArray(value)) { + if (typeof value[0] == "string") { + if (!result) result = [] + result.push(value) + } else { + for (let i = 0; i < value.length; i++) scan(value[i]) + } + } else { + for (let prop in value) scan(value[prop]) + } + } + } + scan(attrs) + return result +} + +function renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null, + blockArraysIn: {[name: string]: any}): { + 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], suspicious + if (typeof tagName != "string") throw new RangeError("Invalid array passed to renderSpec") + if (blockArraysIn && (suspicious = suspiciousAttributes(blockArraysIn)) && + suspicious.indexOf(structure) > -1) + throw new RangeError("Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.") + let 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} = renderSpec(doc, child, xmlNS, blockArraysIn) + dom.appendChild(inner) + if (innerContent) { + if (contentDOM) throw new RangeError("Multiple content holes") + contentDOM = innerContent as HTMLElement + } + } + } + return {dom, contentDOM} +} diff --git a/test/test-dom.ts b/test/test-dom.ts index 7d5943c..7347763 100644 --- a/test/test-dom.ts +++ b/test/test-dom.ts @@ -643,4 +643,13 @@ describe("DOMSerializer", () => { ist((node as HTMLElement).innerHTML, "foobarbazquuxxyz") }) + + it("refuses to use values from attributes as DOM specs", () => { + let weird = new DOMSerializer(Object.assign({}, serializer.nodes, { + image: (node: PMNode) => ["span", ["img", {src: node.attrs.src}], node.attrs.alt] + }), serializer.marks) + ist.throws(() => weird.serializeNode(img({src: "x.png", alt: ["script", {src: "http://evil.com/inject.js"}]}), + {document}), + /Using an array from an attribute object as a DOM spec/) + }) }) From 3360cdccebdda5ee921ae1b81f92985a21844f5b Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 14 Jul 2024 18:56:20 +0200 Subject: [PATCH 103/112] Mark version 1.22.1 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db7efe7..00b26f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.22.1 (2024-07-14) + +### Bug fixes + +Add code to `DOMSerializer` that rejects DOM output specs when they originate from attribute values, to protect against XSS attacks that use corrupt attribute input. + ## 1.22.0 (2024-07-14) ### New features diff --git a/package.json b/package.json index 0edf4d0..e4c742e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.22.0", + "version": "1.22.1", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From e3132400dc33ccd70570a9dc6ddd5505a9fa6ac3 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 18 Jul 2024 16:45:36 +0200 Subject: [PATCH 104/112] Fix a type error --- src/to_dom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/to_dom.ts b/src/to_dom.ts index 9be482d..b41c0df 100644 --- a/src/to_dom.ts +++ b/src/to_dom.ts @@ -182,7 +182,7 @@ function suspiciousAttributesInner(attrs: {[name: string]: any}): readonly any[] } function renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null, - blockArraysIn: {[name: string]: any}): { + blockArraysIn?: {[name: string]: any}): { dom: DOMNode, contentDOM?: HTMLElement } { From fc27a3cc03799767df74c21e44264530240d8ce7 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jul 2024 04:22:13 +1000 Subject: [PATCH 105/112] Include type and attribute name in validate error messages --- src/schema.ts | 16 ++++++++-------- test/test-node.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index ad8c273..f6f45a6 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -47,9 +47,9 @@ export function checkAttrs(attrs: {[name: string]: Attribute}, values: Attrs, ty } } -function initAttrs(attrs?: {[name: string]: AttributeSpec}) { +function initAttrs(typeName: string, 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]) + if (attrs) for (let name in attrs) result[name] = new Attribute(typeName, name, attrs[name]) return result } @@ -75,7 +75,7 @@ export class NodeType { readonly spec: NodeSpec ) { this.groups = spec.group ? spec.group.split(" ") : [] - this.attrs = initAttrs(spec.attrs) + this.attrs = initAttrs(name, spec.attrs) this.defaultAttrs = defaultAttrs(this.attrs) // Filled in later @@ -240,11 +240,11 @@ export class NodeType { } } -function validateType(type: string) { +function validateType(typeName: string, attrName: string, type: string) { let types = type.split("|") return (value: any) => { let name = value === null ? "null" : typeof value - if (types.indexOf(name) < 0) throw new RangeError(`Expected value of type ${types}, got ${name}`) + if (types.indexOf(name) < 0) throw new RangeError(`Expected value of type ${types} for attribute ${attrName} on type ${typeName}, got ${name}`) } } @@ -255,10 +255,10 @@ class Attribute { default: any validate: undefined | ((value: any) => void) - constructor(options: AttributeSpec) { + constructor(typeName: string, attrName: string, options: AttributeSpec) { this.hasDefault = Object.prototype.hasOwnProperty.call(options, "default") this.default = options.default - this.validate = typeof options.validate == "string" ? validateType(options.validate) : options.validate + this.validate = typeof options.validate == "string" ? validateType(typeName, attrName, options.validate) : options.validate } get isRequired() { @@ -291,7 +291,7 @@ export class MarkType { /// The spec on which the type is based. readonly spec: MarkSpec ) { - this.attrs = initAttrs(spec.attrs) + this.attrs = initAttrs(name, spec.attrs) ;(this as any).excluded = null let defaults = defaultAttrs(this.attrs) this.instance = defaults ? new Mark(this, defaults) : null diff --git a/test/test-node.ts b/test/test-node.ts index e3c220a..3920cbd 100644 --- a/test/test-node.ts +++ b/test/test-node.ts @@ -167,7 +167,7 @@ describe("Node", () => { it("notices wrong attribute types", () => { ist.throws(() => schema.nodes.image.create({src: true}).check(), - /Expected value of type string, got boolean/) + /Expected value of type string for attribute src on type image, got boolean/) }) }) From 25285b6dc92c56fad9d018f660a690b06f7498fa Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 18 Jul 2024 20:25:59 +0200 Subject: [PATCH 106/112] Expose the extra blockArraysIn parameter to renderSpec in a hidden way Closes https://github.com/ProseMirror/prosemirror-model/pull/84 --- src/to_dom.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/to_dom.ts b/src/to_dom.ts index b41c0df..b50be17 100644 --- a/src/to_dom.ts +++ b/src/to_dom.ts @@ -111,11 +111,16 @@ export class DOMSerializer { /// 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): { + static renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS?: string | null): { + dom: DOMNode, + contentDOM?: HTMLElement + } + static renderSpec(doc: Document, structure: DOMOutputSpec, xmlNS: string | null = null, + blockArraysIn?: {[name: string]: any}): { dom: DOMNode, contentDOM?: HTMLElement } { - return renderSpec(doc, structure, xmlNS) + return renderSpec(doc, structure, xmlNS, blockArraysIn) } /// Build a serializer using the [`toDOM`](#model.NodeSpec.toDOM) From e1f6e22eeef60816717ecda55a24a1e22d0dd7f3 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 18 Jul 2024 20:32:29 +0200 Subject: [PATCH 107/112] Add release note FIX: Make attribute validation messages more informative. From 343fcd75e3f77c72fa9973b3457302c49b3f2cca Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 18 Jul 2024 20:32:54 +0200 Subject: [PATCH 108/112] Mark version 1.22.2 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b26f6..ef89895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.22.2 (2024-07-18) + +### Bug fixes + +Make attribute validation messages more informative. + ## 1.22.1 (2024-07-14) ### Bug fixes diff --git a/package.json b/package.json index e4c742e..9f207d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.22.1", + "version": "1.22.2", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From 17709d3fc3243906605b672723be92132aa22f8c Mon Sep 17 00:00:00 2001 From: ocavue Date: Thu, 18 Jul 2024 19:16:29 +1000 Subject: [PATCH 109/112] Fix a typo in the comment --- src/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index f6f45a6..ea47d50 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -542,7 +542,7 @@ export interface AttributeSpec { /// created. default?: any /// A function or type name used to validate values of this - /// attibute. This will be used when deserializing the attribute + /// attribute. This will be used when deserializing the attribute /// from JSON, and when running [`Node.check`](#model.Node.check). /// When a function, it should raise an exception if the value isn't /// of the expected type or shape. When a string, it should be a From d47033ef7de41ea5e868d6d541fc0542070b9763 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 6 Aug 2024 16:01:27 +0200 Subject: [PATCH 110/112] Overhaul the way marks are tracked in DOMParser FIX: Fix some corner cases in the way the DOM parser tracks active marks. See https://discuss.prosemirror.net/t/how-to-parse-nested-mark-attributes/6596 --- src/from_dom.ts | 206 +++++++++++++++++------------------------------ test/test-dom.ts | 15 ++++ 2 files changed, 91 insertions(+), 130 deletions(-) diff --git a/src/from_dom.ts b/src/from_dom.ts index 6284227..1f903fa 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -216,7 +216,7 @@ export class DOMParser { /// 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) + context.addAll(dom, Mark.none, options.from, options.to) return context.finish() as Node } @@ -228,7 +228,7 @@ export class DOMParser { /// 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) + context.addAll(dom, Mark.none, options.from, options.to) return Slice.maxOpen(context.finish() as Fragment) } @@ -339,16 +339,11 @@ class NodeContext { // 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 @@ -390,22 +385,6 @@ class NodeContext { 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 @@ -429,12 +408,12 @@ class ParseContext { 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, + topContext = new NodeContext(topNode.type, topNode.attrs, Mark.none, true, options.topMatch || topNode.type.contentMatch, topOptions) else if (isOpen) - topContext = new NodeContext(null, null, Mark.none, Mark.none, true, null, topOptions) + topContext = new NodeContext(null, null, Mark.none, true, null, topOptions) else - topContext = new NodeContext(parser.schema.topNodeType, null, Mark.none, Mark.none, true, null, topOptions) + topContext = new NodeContext(parser.schema.topNodeType, null, Mark.none, true, null, topOptions) this.nodes = [topContext] this.find = options.findPositions this.needsBlock = false @@ -447,25 +426,12 @@ class ParseContext { // 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) + addDOM(dom: DOMNode, marks: readonly Mark[]) { + if (dom.nodeType == 3) this.addTextNode(dom as Text, marks) + else if (dom.nodeType == 1) this.addElement(dom as HTMLElement, marks) } - withStyleRules(dom: HTMLElement, f: () => void) { - let style = dom.style - if (!style || !style.length) return f() - let marks = this.readStyles(dom.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) { + addTextNode(dom: Text, marks: readonly Mark[]) { let value = dom.nodeValue! let top = this.top if (top.options & OPT_PRESERVE_WS_FULL || @@ -489,7 +455,7 @@ class ParseContext { } else { value = value.replace(/\r\n?/g, "\n") } - if (value) this.insertNode(this.parser.schema.text(value)) + if (value) this.insertNode(this.parser.schema.text(value), marks) this.findInText(dom) } else { this.findInside(dom) @@ -498,14 +464,14 @@ class ParseContext { // 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) { + addElement(dom: HTMLElement, marks: readonly Mark[], 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) + this.ignoreFallback(dom, marks) } 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 @@ -518,111 +484,110 @@ class ParseContext { sync = true if (!top.type) this.needsBlock = true } else if (!dom.firstChild) { - this.leafFallback(dom) + this.leafFallback(dom, marks) return } - if (rule && rule.skip) this.addAll(dom) - else this.withStyleRules(dom, () => this.addAll(dom)) + let innerMarks = rule && rule.skip ? marks : this.readStyles(dom, marks) + if (innerMarks) this.addAll(dom, innerMarks) if (sync) this.sync(top) this.needsBlock = oldNeedsBlock } else { - this.withStyleRules(dom, () => { - this.addElementByRule(dom, rule as TagParseRule, rule!.consuming === false ? ruleID : undefined) - }) + let innerMarks = this.readStyles(dom, marks) + if (innerMarks) + this.addElementByRule(dom, rule as TagParseRule, innerMarks, rule!.consuming === false ? ruleID : undefined) } } // Called for leaf DOM nodes that would otherwise be ignored - leafFallback(dom: DOMNode) { + leafFallback(dom: DOMNode, marks: readonly Mark[]) { if (dom.nodeName == "BR" && this.top.type && this.top.type.inlineContent) - this.addTextNode(dom.ownerDocument!.createTextNode("\n")) + this.addTextNode(dom.ownerDocument!.createTextNode("\n"), marks) } // Called for ignored nodes - ignoreFallback(dom: DOMNode) { + ignoreFallback(dom: DOMNode, marks: readonly Mark[]) { // 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("-")) + this.findPlace(this.parser.schema.text("-"), marks) } // 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: CSSStyleDeclaration) { - let add = Mark.none, remove = Mark.none + // return an updated array of marks, or null to indicate some of the + // styles had a rule with `ignore` set. + readStyles(dom: HTMLElement, marks: readonly Mark[]) { + let styles = dom.style // Because many properties will only show up in 'normalized' form // in `style.item` (i.e. text-decoration becomes // text-decoration-line, text-decoration-color, etc), we directly // query the styles mentioned in our rules instead of iterating // over the items. - if (styles.length) for (let i = 0; i < this.parser.matchedStyles.length; i++) { + if (styles && styles.length) for (let i = 0; i < this.parser.matchedStyles.length; i++) { let name = this.parser.matchedStyles[i], value = styles.getPropertyValue(name) if (value) for (let after: StyleParseRule | undefined = undefined;;) { let rule = this.parser.matchStyle(name, value, 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.clearMark) + marks = marks.filter(m => !rule!.clearMark!(m)) + else + marks = marks.concat(this.parser.schema.marks[rule.mark!].create(rule.attrs)) if (rule.consuming === false) after = rule else break } } - return [add, remove] + return marks } // 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 + addElementByRule(dom: HTMLElement, rule: TagParseRule, marks: readonly Mark[], continueAfter?: TagParseRule) { + let sync, nodeType 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) + let inner = this.enter(nodeType, rule.attrs || null, marks, rule.preserveWhitespace) + if (inner) { + sync = true + marks = inner + } + } else if (!this.insertNode(nodeType.create(rule.attrs), marks)) { + this.leafFallback(dom, marks) } } else { let markType = this.parser.schema.marks[rule.mark!] - mark = markType.create(rule.attrs) - this.addPendingMark(mark) + marks = marks.concat(markType.create(rule.attrs)) } let startIn = this.top if (nodeType && nodeType.isLeaf) { this.findInside(dom) } else if (continueAfter) { - this.addElement(dom, continueAfter) + this.addElement(dom, marks, continueAfter) } else if (rule.getContent) { this.findInside(dom) - rule.getContent(dom, this.parser.schema).forEach(node => this.insertNode(node)) + rule.getContent(dom, this.parser.schema).forEach(node => this.insertNode(node, marks)) } 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) + this.addAll(contentDOM, marks) } 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) { + addAll(parent: DOMNode, marks: readonly Mark[], 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.addDOM(dom!, marks) } this.findAtPoint(parent, index) } @@ -630,7 +595,7 @@ class ParseContext { // 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) { + findPlace(node: Node, marks: readonly Mark[]) { let route, sync: NodeContext | undefined for (let depth = this.open; depth >= 0; depth--) { let cx = this.nodes[depth] @@ -642,29 +607,29 @@ class ParseContext { } if (cx.solid) break } - if (!route) return false + if (!route) return null this.sync(sync!) for (let i = 0; i < route.length; i++) - this.enterInner(route[i], null, false) - return true + marks = this.enterInner(route[i], null, marks, false) + return marks } // Try to insert the given node, adjusting the context when needed. - insertNode(node: Node) { + insertNode(node: Node, marks: readonly Mark[]) { if (node.isInline && this.needsBlock && !this.top.type) { let block = this.textblockFromContext() - if (block) this.enterInner(block) + if (block) marks = this.enterInner(block, null, marks) } - if (this.findPlace(node)) { + let innerMarks = this.findPlace(node, marks) + if (innerMarks) { 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)) + let nodeMarks = Mark.none + for (let m of innerMarks.concat(node.marks)) + if (top.type ? top.type.allowsMarkType(m.type) : markMayApply(m.type, node.type)) + nodeMarks = m.addToSet(nodeMarks) + top.content.push(node.mark(nodeMarks)) return true } return false @@ -672,22 +637,31 @@ class ParseContext { // 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 + enter(type: NodeType, attrs: Attrs | null, marks: readonly Mark[], preserveWS?: boolean | "full") { + let innerMarks = this.findPlace(type.create(attrs), marks) + if (innerMarks) innerMarks = this.enterInner(type, attrs, marks, true, preserveWS) + return innerMarks } // Open a node of the given type - enterInner(type: NodeType, attrs: Attrs | null = null, solid: boolean = false, preserveWS?: boolean | "full") { + enterInner(type: NodeType, attrs: Attrs | null, marks: readonly Mark[], + 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)) + let applyMarks = Mark.none + marks = marks.filter(m => { + if (top.type ? top.type.allowsMarkType(m.type) : markMayApply(m.type, type)) { + applyMarks = m.addToSet(applyMarks) + return false + } + return true + }) + this.nodes.push(new NodeContext(type, attrs, applyMarks, solid, null, options)) this.open++ + return marks } // Make sure all nodes above this.open are finished and added to @@ -799,28 +773,6 @@ class ParseContext { 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 @@ -870,9 +822,3 @@ function markMayApply(markType: MarkType, nodeType: NodeType) { 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] - } -} diff --git a/test/test-dom.ts b/test/test-dom.ts index 7347763..e6c1916 100644 --- a/test/test-dom.ts +++ b/test/test-dom.ts @@ -408,6 +408,21 @@ describe("DOMParser", () => { ), 1, 1), eq) }) + it("can temporary shadow a mark with another configuration of the same type", () => { + let s = new Schema({nodes: schema.spec.nodes, marks: {color: { + attrs: {color: {}}, + toDOM: m => ["span", {style: `color: ${m.attrs.color}`}], + parseDOM: [{style: "color", getAttrs: v => ({color: v})}] + }}}) + let d = DOMParser.fromSchema(s) + .parse(domFrom('

    abcdefghi

    ')) + ist(d, s.node("doc", null, [s.node("paragraph", null, [ + s.text("abc", [s.mark("color", {color: "red"})]), + s.text("def", [s.mark("color", {color: "blue"})]), + s.text("ghi", [s.mark("color", {color: "red"})]) + ])]), eq) + }) + function find(html: string, doc: PMNode) { return () => { let dom = document.createElement("div") From be711f9c54143aa1965cdf51c091a1b2d8684e4b Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 6 Aug 2024 16:01:55 +0200 Subject: [PATCH 111/112] Mark version 1.22.3 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef89895..1ea5d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.22.3 (2024-08-06) + +### Bug fixes + +Fix some corner cases in the way the DOM parser tracks active marks. + ## 1.22.2 (2024-07-18) ### Bug fixes diff --git a/package.json b/package.json index 9f207d1..312a254 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-model", - "version": "1.22.2", + "version": "1.22.3", "description": "ProseMirror's document model", "type": "module", "main": "dist/index.cjs", From a57531574336d00df43a68e3a2f69582b426033e Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Thu, 22 Aug 2024 14:53:21 +0200 Subject: [PATCH 112/112] Make sure DOMParser can find node positions after contentDOM nodes --- src/from_dom.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/from_dom.ts b/src/from_dom.ts index 1f903fa..c131901 100644 --- a/src/from_dom.ts +++ b/src/from_dom.ts @@ -574,6 +574,7 @@ class ParseContext { else if (rule.contentElement) contentDOM = rule.contentElement this.findAround(dom, contentDOM, true) this.addAll(contentDOM, marks) + this.findAround(dom, contentDOM, false) } if (sync && this.sync(startIn)) this.open-- }