|
13 | 13 | .replace(/'/g, "'");
|
14 | 14 | }
|
15 | 15 |
|
16 |
| - /* A model for the content of a document. The API handles it separately |
17 |
| - * to the document metadata since the content can be very big. |
| 16 | + /** |
| 17 | + * A model for the content of a document. The API handles it separately to the document metadata since the content |
| 18 | + * can be very big. |
| 19 | + * |
| 20 | + * The model also manages an XML DOM form of the document content, and updates the DOM if the text content changes. |
| 21 | + * The reverse is not true: the text content is not kept up to date for performance reasons. If the text content |
| 22 | + * is required, it must first be serialised with toXml(). |
18 | 23 | *
|
19 |
| - * The model also manages an XML DOM form of the document content and keeps |
20 |
| - * the two in sync. |
| 24 | + * This model fires custom events: |
21 | 25 | *
|
22 |
| - * This model fires an additional 'change:dom' event when the XML DOM |
23 |
| - * tree itself has changed due to a modification by this model. |
| 26 | + * - mutation - when the XML DOM is manipulated by any means, based on the MutationObserver class. The parameter |
| 27 | + * for the event is a MutationRecord object. |
| 28 | + * - change:dom - when the XML DOM is manipulated by any means, after all the mutation events have been fired. |
24 | 29 | */
|
25 | 30 | Indigo.DocumentContent = Backbone.Model.extend({
|
26 | 31 | initialize: function(options) {
|
27 | 32 | this.document = options.document;
|
28 | 33 | this.document.content = this;
|
29 | 34 | this.xmlDocument = null;
|
| 35 | + this.observer = null; |
30 | 36 | this.on('change:content', this.contentChanged, this);
|
31 |
| - this.on('change:dom', this.domChanged, this); |
32 | 37 | },
|
33 | 38 |
|
34 | 39 | isNew: function() {
|
|
40 | 45 | return this.document.url() + '/content';
|
41 | 46 | },
|
42 | 47 |
|
| 48 | + setupMutationObserver: function () { |
| 49 | + this.observer = new MutationObserver((mutations) => { |
| 50 | + for (const mutation of mutations) { |
| 51 | + console.log('mutation', mutation); |
| 52 | + this.trigger('mutation', this, mutation); |
| 53 | + } |
| 54 | + this.trigger('change:dom', this); |
| 55 | + this.trigger('change', this); |
| 56 | + }); |
| 57 | + |
| 58 | + this.observer.observe(this.xmlDocument, { |
| 59 | + childList: true, |
| 60 | + attributes: true, |
| 61 | + subtree: true, |
| 62 | + }); |
| 63 | + }, |
| 64 | + |
| 65 | + /** |
| 66 | + * Determine the impact of a mutation on the provided element. |
| 67 | + * @param mutation MutationRecord |
| 68 | + * @param element Element in this XML document |
| 69 | + * @returns 'changed' if the mutation impacts the element, 'removed' if the element was removed from the tree, |
| 70 | + * 'replaced', if the element has been replaced (with a node in mutation.addedNodes), |
| 71 | + * or null if there is no impact |
| 72 | + */ |
| 73 | + getMutationImpact(mutation, element) { |
| 74 | + const target = mutation.target; |
| 75 | + |
| 76 | + if (mutation.type === 'childList') { |
| 77 | + if (mutation.removedNodes[0] === element) { |
| 78 | + if (mutation.addedNodes.length === 0) { |
| 79 | + // the element itself was removed |
| 80 | + return 'removed'; |
| 81 | + } |
| 82 | + |
| 83 | + // the element has been replaced |
| 84 | + return 'replaced'; |
| 85 | + } |
| 86 | + |
| 87 | + const ownerDocument = target.nodeType === Node.DOCUMENT_NODE ? target : target.ownerDocument; |
| 88 | + if (!ownerDocument.contains(element)) { |
| 89 | + // the change removed xmlElement from the tree |
| 90 | + return 'removed'; |
| 91 | + } |
| 92 | + } |
| 93 | + |
| 94 | + if (target === element || element.contains(target)) { |
| 95 | + // the mutated node is xmlElement itself or one of its descendants |
| 96 | + return 'changed'; |
| 97 | + } |
| 98 | + |
| 99 | + // we don't care about attribute or character data changes elsewhere in the document |
| 100 | + }, |
| 101 | + |
43 | 102 | contentChanged: function(model, newValue, options) {
|
44 |
| - // don't bother updating the DOM if the source of this event |
45 |
| - // is already a change to the DOM |
46 |
| - if (options && options.fromXmlDocument) return; |
| 103 | + let root = null; |
47 | 104 |
|
48 | 105 | try {
|
49 |
| - this.xmlDocument = $.parseXML(newValue); |
| 106 | + root = Indigo.parseXml(newValue); |
50 | 107 | } catch(e) {
|
51 | 108 | Indigo.errorView.show("The document has invalid XML.");
|
52 | 109 | return;
|
53 | 110 | }
|
54 | 111 |
|
55 |
| - options.fromContent = true; |
56 |
| - this.trigger('change:dom', model, options); |
57 |
| - }, |
| 112 | + if (!this.xmlDocument) { |
| 113 | + this.xmlDocument = root; |
| 114 | + this.setupMutationObserver(); |
| 115 | + this.trigger('change:dom', this); |
| 116 | + this.trigger('change', this); |
| 117 | + } else { |
| 118 | + root = root.documentElement; |
| 119 | + this.xmlDocument.adoptNode(root); |
| 120 | + this.xmlDocument.documentElement.replaceWith(root); |
| 121 | + } |
58 | 122 |
|
59 |
| - domChanged: function(model, options) { |
60 |
| - // don't bother updating the content if this event was |
61 |
| - // originally triggered by a content change |
62 |
| - if (options && options.fromContent) return; |
| 123 | + // clear the content, so that any change later will always trigger a change event, because we don't keep |
| 124 | + // the content synced with the changes in the DOM |
| 125 | + this.set('content', '', {silent: true}); |
| 126 | + }, |
63 | 127 |
|
| 128 | + /** |
| 129 | + * Rewrite all eIds and component names to ensure they are correct. This should be done after the DOM structure |
| 130 | + * is changed. |
| 131 | + */ |
| 132 | + rewriteIds: function() { |
64 | 133 | // rewrite all eIds before setting the content
|
65 | 134 | // in provision mode, retain the eId of the parent element as the prefix
|
66 | 135 | let eidPrefix;
|
|
70 | 139 | new indigoAkn.EidRewriter().rewriteAllEids(this.xmlDocument.documentElement, eidPrefix);
|
71 | 140 | // rewrite all attachment FRBR URI work components too
|
72 | 141 | new indigoAkn.WorkComponentRewriter().rewriteAllAttachmentWorkComponents(this.xmlDocument.documentElement);
|
73 |
| - this.set('content', this.toXml(), {fromXmlDocument: true}); |
74 | 142 | },
|
75 | 143 |
|
76 | 144 | // serialise an XML node, or the entire document if node is not given, to a string
|
|
100 | 168 |
|
101 | 169 | if (!oldNode || !oldNode.parentElement) {
|
102 | 170 | if (del) {
|
103 |
| - // TODO: we don't currently support deleting whole document |
104 | 171 | throw "Cannot currently delete the entire document.";
|
105 | 172 | }
|
106 | 173 |
|
107 | 174 | // entire document has changed
|
108 | 175 | if (newNodes.length !== 1) {
|
109 | 176 | throw "Expected exactly one newNode, got " + newNodes.length;
|
110 | 177 | }
|
111 |
| - console.log('Replacing whole document'); |
112 |
| - this.xmlDocument = first.ownerDocument; |
| 178 | + this.xmlDocument.adoptNode(first); |
| 179 | + this.xmlDocument.documentElement.replaceWith(first); |
113 | 180 |
|
114 | 181 | } else {
|
115 | 182 | if (del) {
|
|
121 | 188 | // just a fragment has changed
|
122 | 189 | console.log('Replacing node with ' + newNodes.length + ' new node(s)');
|
123 | 190 |
|
124 |
| - first = oldNode.ownerDocument.importNode(first, true); |
| 191 | + oldNode.ownerDocument.adoptNode(first); |
125 | 192 | oldNode.parentElement.replaceChild(first, oldNode);
|
126 | 193 |
|
127 | 194 | // now append the other nodes, starting at the end
|
128 | 195 | // because it makes the insert easier
|
129 | 196 | for (var i = newNodes.length-1; i > 0; i--) {
|
130 |
| - var node = first.ownerDocument.importNode(newNodes[i], true); |
| 197 | + const node = newNodes[i]; |
| 198 | + first.ownerDocument.adoptNode(node); |
131 | 199 |
|
132 | 200 | if (first.nextElementSibling) {
|
133 | 201 | first.parentElement.insertBefore(node, first.nextElementSibling);
|
|
138 | 206 | }
|
139 | 207 | }
|
140 | 208 |
|
141 |
| - // domChanged will rewrite all eIds |
142 |
| - this.trigger('change:dom'); |
| 209 | + this.rewriteIds(); |
| 210 | + |
143 | 211 | return first;
|
144 | 212 | },
|
145 | 213 |
|
|
159 | 227 | return this.xmlDocument.evaluate(expression, context, nsLookup, result);
|
160 | 228 | },
|
161 | 229 |
|
162 |
| - /** Get an array of <act> and <doc> elements for this document. |
163 |
| - */ |
164 |
| - componentElements: function() { |
165 |
| - var components = [], |
166 |
| - result = this.xpath('/a:akomaNtoso/a:act/a:meta | /a:akomaNtoso/a:act/a:attachments/a:attachment/a:*/a:meta'); |
167 |
| - |
168 |
| - for (var i = 0; i < result.snapshotLength; i++) { |
169 |
| - components.push(result.snapshotItem(i).parentElement); |
170 |
| - } |
171 |
| - |
172 |
| - return components; |
173 |
| - }, |
174 |
| - |
175 | 230 | /** Get an element by id, which is potentially scoped to a component (eg. "schedule1/table-1").
|
176 | 231 | * @param scopedId
|
177 | 232 | */
|
|
188 | 243 | save: function(options) {
|
189 | 244 | // When saving document contents, save all document details, so that we capture all
|
190 | 245 | // changes in a single revision on the server.
|
191 |
| - // We do this by delegating to the document object. |
192 |
| - let content = this.get('content'); |
| 246 | + // We do this by delegating the actual save to the document object. |
| 247 | + |
| 248 | + // serialise the xml from the live DOM |
| 249 | + let content = this.toXml(); |
| 250 | + |
193 | 251 | if (Indigo.Preloads.provisionEid) {
|
194 | 252 | content = `<akomaNtoso xmlns="${this.xmlDocument.firstChild.getAttribute('xmlns')}">${content}</akomaNtoso>`;
|
195 | 253 | }
|
196 | 254 | this.document.attributes.content = content;
|
197 | 255 | this.document.attributes.provision_eid = Indigo.Preloads.provisionEid;
|
198 |
| - var result = this.document.save(); |
199 |
| - // XXX works around https://github.com/Code4SA/indigo/issues/20 by not parsing |
200 |
| - // the response to the save() call |
| 256 | + const result = this.document.save(); |
| 257 | + // don't re-parse the content in the response to the save() call |
201 | 258 | delete this.document.attributes.content;
|
202 | 259 | this.document.setClean();
|
203 | 260 | this.trigger('sync');
|
|
256 | 313 | parts.push(this.get('number'));
|
257 | 314 |
|
258 | 315 | // clean the parts
|
259 |
| - parts = _.map(parts, function(p) { return (p || "").replace(/[ \/]/g, ''); }); |
| 316 | + parts = _.map(parts, function(p) { return (p || "").replace(/[ /]/g, ''); }); |
260 | 317 |
|
261 | 318 | this.set('frbr_uri', parts.join('/').toLowerCase());
|
262 | 319 | },
|
|
445 | 502 | return url;
|
446 | 503 | },
|
447 | 504 |
|
448 |
| - setWork: function(work) { |
449 |
| - this.set('frbr_uri', work.get('frbr_uri')); |
450 |
| - this.work = work; |
451 |
| - this.trigger('change change:work'); |
452 |
| - }, |
453 |
| - |
454 | 505 | /** Get the Tradition description for this document's country.
|
455 | 506 | */
|
456 | 507 | tradition: function() {
|
|
0 commit comments