' +
+ noMarkup(2) +
+ 'sdfsdfsdfsdf dsfsdfsdfdsflkewjrl ksjfl ksdjf klnlkjBavaria ipsum dolor sit amet Biazelt Auffisteign ' +
+ brMarkup(3) +
+ 'Schorsch mim Radl foahn Ohrwaschl Steckerleis wann griagd ma nacha wos z’dringa glacht Mamalad, ' +
+ brMarkup(4) +
+ 'muass? I bin a woschechta Bayer sowos oamoi und sei und glei wirds no fui lustiga: Jo mei khkhis des ' +
+ brMarkup(5) +
+ 'schee middn ognudelt, Trachtnhuat Biawambn gscheid: Griasd eich midnand etza nix Gwiass woass ma ned ' +
+ brMarkup(6) +
+ 'owe. Dahoam gscheckate middn Spuiratz des is a gmahde Wiesn. Des is schee so Obazda san da, Haferl ' +
+ brMarkup(7) +
+ 'pfenningguat schoo griasd eich midnand.
\
+
\
+
' +
+ noMarkup(8) +
+ 'Auffi Gamsbart nimma de Sepp Ledahosn Ohrwaschl um Godds wujn Wiesn Deandlgwand Mongdratzal! Jo ' +
+ brMarkup(9) +
+ 'leck mi Mamalad i daad mechad?
\
+
' +
+ noMarkup(10) +
+ 'Do nackata Wurscht i hob di narrisch gean, Diandldrahn Deandlgwand vui huift vui woaß?
\
+
' +
+ noMarkup(11) +
+ 'Ned Mamalad auffi i bin a woschechta Bayer greaßt eich nachad, umananda gwiss nia need ' +
+ brMarkup(12) +
+ 'Weiznglasl.
\
+
' +
+ noMarkup(13) +
+ 'Woibbadinga noch da Giasinga Heiwog Biazelt mechad mim Spuiratz, soi zwoa.
\
+
\
+
' +
+ noMarkup(14) +
+ 'I waar soweid Blosmusi es nomoi. Broadwurschtbudn des is a gmahde Wiesn Kirwa mogsd a Bussal ' +
+ brMarkup(15) +
+ 'Guglhupf schüds nei. Luja i moan oiwei Baamwach Watschnbaam, wiavui baddscher! Biakriagal a fescha ' +
+ brMarkup(16) +
+ '1Bua Semmlkneedl iabaroi oba um Godds wujn Ledahosn wui Greichats. Geh um Godds wujn luja heid ' +
+ brMarkup(17) +
+ 'greaßt eich nachad woaß Breihaus eam! De om auf’n Gipfe auf gehds beim Schichtl mehra Baamwach a ' +
+ brMarkup(18) +
+ 'bissal wos gehd ollaweil gscheid:
\
+
\
+
' +
+ noMarkup(19) +
+ 'Scheans Schdarmbeaga See i hob di narrisch gean i jo mei is des schee! Nia eam ' +
+ brMarkup(20) +
+ 'hod vasteh i sog ja nix, i red ja bloß sammawiedaguad, umma eana obandeln! Zwoa ' +
+ brMarkup(21) +
+ 'jo mei scheans amoi, san und hoggd Milli barfuaßat gscheit. Foidweg vui huift ' +
+ brMarkup(22) +
+ 'vui singan, mehra Biakriagal om auf’n Gipfe! Ozapfa sodala Charivari greaßt eich ' +
+ brMarkup(23) +
+ 'nachad Broadwurschtbudn do middn liberalitas Bavariae sowos Leonhardifahrt:
\
+
\
+
' +
+ noMarkup(24) +
+ 'Wui helfgod Wiesn, ognudelt schaugn: Dahoam gelbe Rüam Schneid singan wo hi sauba i moan scho aa no ' +
+ brMarkup(25) +
+ 'a Maß a Maß und no a Maß nimma. Is umananda a ganze Hoiwe zwoa, Schneid. Vui huift vui Brodzeid kumm ' +
+ brMarkup(26) +
+ 'geh naa i daad vo de allerweil, gor. Woaß wia Gams, damischa. A ganze Hoiwe Ohrwaschl Greichats ' +
+ brMarkup(27) +
+ 'iabaroi Prosd Engelgwand nix Reiwadatschi.Weibaleid ognudelt Ledahosn noch da Giasinga Heiwog i daad ' +
+ brMarkup(28) +
+ 'Almrausch, Ewig und drei Dog nackata wea ko, dea ko. Meidromml Graudwiggal nois dei, nackata. No ' +
+ brMarkup(29) +
+ 'Diandldrahn nix Gwiass woass ma ned hod boarischer: Samma sammawiedaguad wos, i hoam Brodzeid. Jo ' +
+ brMarkup(30) +
+ 'mei Sepp Gaudi, is ma Wuascht do Hendl Xaver Prosd eana an a bravs. Sauwedda an Brezn, abfieseln.
');
+ expect(diff.previousHtmlEndSnippet).toBe('');
+
+ // @TODO in followingHtmlStartSnippet, os-split-li is not set yet in this case.
+ // This is not entirely correct, but as this field is never actually used, it's not bothering (yet)
+ // This comment remains to document a potential pitfall in the future
+ // expect(diff.followingHtmlStartSnippet).toBe('
');
+ }));
+
+ it('extracts a single line right before a UL/LI', inject(
+ [DiffService, LinenumberingService],
+ (service: DiffService, lineNumbering: LinenumberingService) => {
+ // Test case for https://github.com/OpenSlides/OpenSlides/issues/3226
+ let html =
+ '
\n');
+ }
+ ));
+
+ it('extracts lines from a more complex example', inject([DiffService], (service: DiffService) => {
+ const diff = service.extractRangeByLineNumbers(baseHtml2, 6, 11);
+
+ expect(diff.html).toBe(
+ 'owe. Dahoam gscheckate middn Spuiratz des is a gmahde Wiesn. Des is schee so Obazda san da, Haferl pfenningguat schoo griasd eich midnand.
Auffi Gamsbart nimma de Sepp Ledahosn Ohrwaschl um Godds wujn Wiesn Deandlgwand Mongdratzal! Jo leck mi Mamalad i daad mechad?
Do nackata Wurscht i hob di narrisch gean, Diandldrahn Deandlgwand vui huift vui woaß?
');
+ }));
+
+ it('extracts the end of a section', inject([DiffService], (service: DiffService) => {
+ const diff = service.extractRangeByLineNumbers(baseHtml2, 29, null);
+
+ expect(diff.html).toBe(
+ 'Diandldrahn nix Gwiass woass ma ned hod boarischer: Samma sammawiedaguad wos, i hoam Brodzeid. Jo mei Sepp Gaudi, is ma Wuascht do Hendl Xaver Prosd eana an a bravs. Sauwedda an Brezn, abfieseln.'
+ );
+ expect(diff.ancestor.nodeName).toBe('#document-fragment');
+ expect(diff.outerContextStart).toBe('');
+ expect(diff.outerContextEnd).toBe('');
+ expect(diff.innerContextStart).toBe('
'
+ );
+ }));
+
+ it('breaks up a paragraph into two', inject([DiffService], (service: DiffService) => {
+ const merged = service.replaceLines(baseHtml1, '
Replaced Line 10
Inserted Line 11
', 10, 11);
+ expect(merged).toBe(
+ '
Line 1 Line 2 Line 3 Line 4 Line 5
Line 6 Line 7
Level 2 LI 8
Level 2 LI 9
Replaced Line 10
Inserted Line 11 Line 11
'
+ );
+ }));
+
+ it('does not accidently merge two separate words', inject([DiffService], (service: DiffService) => {
+ const merged = service.replaceLines(baseHtml1, '
Line 1INSERTION
', 1, 2),
+ containsError = merged.indexOf('Line 1INSERTIONLine 2'),
+ containsCorrectVersion = merged.indexOf('Line 1INSERTION Line 2');
+ expect(containsError).toBe(-1);
+ expect(containsCorrectVersion).toBe(3);
+ }));
+
+ it('does not accidently merge two separate words, even in lists', inject(
+ [DiffService],
+ (service: DiffService) => {
+ // The newlines between UL and LI are the problem here
+ const merged = service.replaceLines(
+ baseHtml1,
+ '
');
+ }));
+ });
+
+ describe('the core diff algorithm', () => {
+ it('acts as documented by the official documentation', inject([DiffService], (service: DiffService) => {
+ const before = 'The red brown fox jumped over the rolling log.',
+ after = 'The brown spotted fox leaped over the rolling log.';
+ const diff = service.diff(before, after);
+ expect(diff).toBe(
+ 'The red brown spotted fox jumleaped over the rolling log.'
+ );
+ }));
+
+ it('ignores changing cases in HTML tags', inject([DiffService], (service: DiffService) => {
+ const before = 'The brown spotted fox jumped over the rolling log.',
+ after = 'The brown spotted fox leaped over the rolling log.';
+ const diff = service.diff(before, after);
+
+ expect(diff).toBe(
+ 'The brown spotted fox jumleaped over the rolling log.'
+ );
+ }));
+
+ it('does not insert spaces after a unchanged BR tag', inject([DiffService], (service: DiffService) => {
+ const before = '
';
+ const diff = service.diff(before, after);
+
+ expect(diff).toBe(before);
+ }));
+
+ it('does not mark the last line of a paragraph as change if a long new one is appended', inject(
+ [DiffService],
+ (service: DiffService) => {
+ const before =
+ '
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
',
+ after =
+ '
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
\n' +
+ '\n' +
+ '
Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu.
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
\n' +
+ '
Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu.
'
+ );
+ }
+ ));
+
+ it('does not result in separate paragraphs when only the first word has changed', inject(
+ [DiffService],
+ (service: DiffService) => {
+ const before =
+ '
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
',
+ after =
+ '
Bla ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
liebliche Stimme, aber deine Stimme ist rauh; du bist der Wolf.' Da gieng der
",
+ after =
+ "
liebliche Stimme, aber deine Stimme ist rauh; du bist der Wolf.'
\
+\
+
Der Wolf hatte danach richtig schlechte laune, trank eine Flasche Rum,
\
+\
+
machte eine Weltreise und kam danach wieder um die Ziegen zu fressen. Da ging der
",
+ expected =
+ '
liebliche Stimme, aber deine Stimme ist rauh; du bist der Wolf.\' Da gieng der
' +
+ '
liebliche Stimme, aber deine Stimme ist rauh; du bist der Wolf.\'
' +
+ '
Der Wolf hatte danach richtig schlechte laune, trank eine Flasche Rum,
' +
+ '
machte eine Weltreise und kam danach wieder um die Ziegen zu fressen. Da ging der
';
+
+ const diff = service.diff(before, after);
+ expect(diff).toBe(expected);
+ }));
+
+ it('handles inserted paragraphs (2)', inject([DiffService], (service: DiffService) => {
+ // Specifically, Noch should not be enclosed by ..., as Noch would be seriously broken
+ const before =
+ "
rief sie alle sieben herbei und sprach 'liebe Kinder, ich will hinaus in den Wald, seid
",
+ after =
+ "
rief sie alle sieben herbei und sprach 'liebe Kinder, ich will hinaus in den Wald, seid Noch
" +
+ '
Test 123
',
+ expected =
+ "
rief sie alle sieben herbei und sprach 'liebe Kinder, ich will hinaus in den Wald, seid Noch
" +
+ '
Test 123
';
+
+ const diff = service.diff(before, after);
+ expect(diff).toBe(expected);
+ }));
+
+ it('handles insterted paragraphs (3)', inject([DiffService], (service: DiffService) => {
+ // Hint: os-split-after should be moved from the first paragraph to the second one
+ const before =
+ '
Lorem ipsum dolor sit amet, consetetur sadipscing elitr,
',
+ after =
+ '
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
\n' +
+ '\n' +
+ '
Stet clita kasd gubergren, no sea takimata sanctus est.
',
+ expected =
+ '
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
\n' +
+ '
Stet clita kasd gubergren, no sea takimata sanctus est.
Einfügung 1 ...so frißt er Euch alle mit Haut und Haar und Augen und Därme und alles.
\n
Test
'
+ );
+ }
+ ));
+
+ it('does not lose formattings when multiple lines are deleted', inject(
+ [DiffService],
+ (service: DiffService) => {
+ const before =
+ '
' +
+ noMarkup(13) +
+ 'diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd ' +
+ brMarkup(14) +
+ 'gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
' +
+ noMarkup(13) +
+ 'diam voluptua. at vero eos et accusam et justo duo dolores et ea rebum. stet clita kasd ' +
+ brMarkup(14) +
+ 'gubergren, no sea takimata sanctus est lorem ipsum dolor sit amet.' +
+ 'test
';
+
+ expect(diff).toBe(expected.toLowerCase());
+ }
+ ));
+
+ it('removed inline colors in inserted/deleted parts (1)', inject([DiffService], (service: DiffService) => {
+ const before = '
'
+ );
+ }));
+
+ it('marks a single moved word as deleted and inserted again', inject([DiffService], (service: DiffService) => {
+ const before =
+ '
tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren bla, no sea takimata sanctus est Lorem ipsum dolor sit amet.
',
+ after =
+ '
tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd bla, no sea takimata sanctus est Lorem ipsum dolor gubergren sit amet.
tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren bla, no sea takimata sanctus est Lorem ipsum dolor gubergren sit amet.
'
+ );
+ }));
+
+ it('works with style-tags in spans', inject([DiffService], (service: DiffService) => {
+ const before =
+ '
sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing
',
+ after =
+ '
sanctus est Lorem ipsum dolor sit amet. Test Lorem ipsum dolor sit amet, consetetur sadipscing
sanctus est Lorem ipsum dolor sit amet. Test Lorem ipsum dolor sit amet, consetetur sadipscing
'
+ );
+ }));
+
+ it('does not lose words when changes are moved X-wise', inject([DiffService], (service: DiffService) => {
+ const before = 'elitr. einsetzt. VERSCHLUCKT noch die sog. Gleichbleibend (Wird gelöscht).',
+ after = 'elitr, Einfügung durch Änderung der Gleichbleibend, einsetzt.';
+
+ const diff = service.diff(before, after);
+ expect(diff).toBe(
+ 'elitr. einsetzt. VERSCHLUCKT noch die sog., Einfügung durch Änderung der Gleichbleibend (Wird gelöscht)., einsetzt.'
+ );
+ }));
+
+ it('does not fall back to block level replacement when BRs are inserted/deleted', inject(
+ [DiffService],
+ (service: DiffService) => {
+ const before =
+ '
Lorem ipsum dolor sit amet, consetetur sadipscing elitr. Bavaria ipsum dolor sit amet o’ha wea nia ausgähd kummt nia hoam i hob di narrisch gean
',
+ after =
+ '
Lorem ipsum dolor sit amet, consetetur sadipscing elitr. Sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.. \n' +
+ 'Bavaria ipsum dolor sit amet o’ha wea nia ausgähd \n' +
+ 'Autonomie erfährt ihre Grenzen
Lorem ipsum dolor sit amet, consetetur sadipscing elitr. Sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.. Bavaria ipsum dolor sit amet o’ha wea nia ausgähd kummt nia hoam i hob di narrisch geanAutonomie erfährt ihre Grenzen
'
+ );
+ }
+ ));
+
+ it('does not a change in a very specific case', inject([DiffService], (service: DiffService) => {
+ // See diff._fixWrongChangeDetection
+ const inHtml =
+ '
Test 123 wir strikt ab. lehnen wir ' +
+ brMarkup(1486) +
+ 'ab. ' +
+ noMarkup(1487) +
+ 'Gegenüber
',
+ outHtml = '
Test 123 \n' + 'wir strikt ab. lehnen wir ab. \n' + 'Gegenüber
' +
+ noMarkup(2) +
+ 'their grammar, their pronunciation and their most common words. Everyone ' +
+ brMarkup(3) +
+ 'realizes why a
\n' +
+ '
NEW PARAGRAPH 2.
'
+ );
+ }
+ ));
+
+ it('works with two inserted paragraphs', inject(
+ [DiffService, LinenumberingService],
+ (service: DiffService, lineNumbering: LinenumberingService) => {
+ // Hint: If the last paragraph is a P again, the Diff still fails and falls back to paragraph-based diff
+ // This leaves room for future improvements
+ let before =
+ '
their grammar, their pronunciation and their most common words. Everyone realizes why a
\n
Go on
';
+ const after =
+ '
their grammar, their pronunciation and their most common words. Everyone realizes why a
' +
+ noMarkup(2) +
+ 'their grammar, their pronunciation and their most common words. Everyone ' +
+ brMarkup(3) +
+ 'realizes why a
\n' +
+ '
NEW PARAGRAPH 1.
\n' +
+ '
NEW PARAGRAPH 2.
\n' +
+ '
' +
+ noMarkup(4) +
+ 'Go on
'
+ );
+ }
+ ));
+
+ it('detects broken HTML and lowercases class names', inject([DiffService], (service: DiffService) => {
+ const before =
+ '
holen, da rief sie alle sieben herbei und sprach:
\n\n
"Liebe Kinder, ich will hinaus in den Wald, seid auf der Hut vor dem Wolf! Wenn er hereinkommt, frisst er euch alle mit Haut und Haar. Der Bösewicht verstellt sich oft, aber an der rauen Stimme und an seinen schwarzen Füßen werdet ihr ihn schon erkennen."
\n\n
Die Geißlein sagten: " Liebe Mutter, wir wollen uns schon in acht nehmen, du kannst ohne
',
+ after =
+ '
holen, da rief sie alle sieben herbei und sprach:
\n\n
Hello
\n\n
World
\n\n
Ya
\n\n
Die Geißlein sagten: " Liebe Mutter, wir wollen uns schon in acht nehmen, du kannst ohne
"Liebe Kinder, ich will hinaus in den Wald, seid auf der Hut vor dem Wolf! Wenn er hereinkommt, frisst er euch alle mit Haut und Haar. Der Bösewicht verstellt sich oft, aber an der rauen Stimme und an seinen schwarzen Füßen werdet ihr ihn schon erkennen."
\n\n
Die Geißlein sagten: " Liebe Mutter, wir wollen uns schon in acht nehmen, du kannst ohne
' +
+ '
holen, da rief sie alle sieben herbei und sprach:
\n\n' +
+ '
Hello
\n\n' +
+ '
World
\n\n' +
+ '
Ya
\n\n' +
+ '
Die Geißlein sagten: " Liebe Mutter, wir wollen uns schon in acht nehmen, du kannst ohne
'
+ );
+ }));
+
+ it('line breaks at dashes does not delete/insert the last/first word of the split lines', inject(
+ [DiffService, LinenumberingService],
+ (service: DiffService, lineNumbering: LinenumberingService) => {
+ let before =
+ '
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy ei rmodtem-Porinv idunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
';
+ const after =
+ '
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy ei rmodtem-Porinv idunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
' +
+ noMarkup(1) +
+ 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy ei rmodtem-' +
+ brMarkup(2) +
+ 'Porinv idunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
');
+ }));
+ });
+});
diff --git a/client/src/app/site/motions/services/diff.service.ts b/client/src/app/site/motions/services/diff.service.ts
new file mode 100644
index 000000000..39a2ae4ea
--- /dev/null
+++ b/client/src/app/site/motions/services/diff.service.ts
@@ -0,0 +1,2031 @@
+import { Injectable } from '@angular/core';
+import { LinenumberingService } from './linenumbering.service';
+import { ViewMotion } from '../models/view-motion';
+import { ViewUnifiedChange } from '../models/view-unified-change';
+
+const ELEMENT_NODE = 1;
+const TEXT_NODE = 3;
+const DOCUMENT_FRAGMENT_NODE = 11;
+
+/**
+ * Indicates the type of a modification when comparing ("diff"ing) two versions of a text.
+ * - TYPE_INSERTION indicates an insertion. An insertion is when the new version of a text contains a certain string
+ * that did not exist in the original version of the.
+ * - TYPE_DELETION indicates a replacement. A deletion is when the new version of a text does not contain a certain
+ * string contained in the original version of the text anymore.
+ * - TYPE_REPLACEMENT indicates both of the above: the new version of the text contains text not present in the original
+ * version, but also removes some parts of that text.
+ *
+ * This enumeration is used when _automatically_ detecting the change type of an amendment / change recommendation.
+ */
+export enum ModificationType {
+ TYPE_REPLACEMENT,
+ TYPE_INSERTION,
+ TYPE_DELETION
+}
+
+/**
+ * This data structure is used when determining the most specific common ancestor of two HTML nodes (`node1` and `node2`)
+ * within the same Document Fragment.
+ */
+interface CommonAncestorData {
+ /**
+ * The most specific common ancestor node.
+ */
+ commonAncestor: Node;
+ /**
+ * The nodes inbetween `commonAncestor` and the `node1` in the DOM hierarchy. Empty, if node1 is a direct descendant.
+ */
+ trace1: Node[];
+ /**
+ * The nodes inbetween `commonAncestor` and the `node2` in the DOM hierarchy. Empty, if node2 is a direct descendant.
+ */
+ trace2: Node[];
+ /**
+ * Starting the root node, this indicates the depth level of the `commonAncestor`.
+ */
+ index: number;
+}
+
+/**
+ * An object produced by `extractRangeByLineNumbers``. It contains both the extracted lines as well as
+ * information about the context in which these lines occur.
+ * This additional information is meant to render the snippet correctly without producing broken HTML
+ */
+interface ExtractedContent {
+ /**
+ * The HTML between the two line numbers. Line numbers and automatically set line breaks are stripped.
+ * All HTML tags are converted to uppercase
+ * (e.g. Line 2
Line3
Line 4 )
+ */
+ html: string;
+ /**
+ * The most specific DOM element that contains the HTML snippet (e.g. a UL, if several LIs are selected)
+ */
+ ancestor: Node;
+ /**
+ * An HTML string that opens all necessary tags to get the browser into the rendering mode
+ * of the ancestor element (e.g.
in the case of the multiple LIs)
+ */
+ outerContextStart: string;
+ /**
+ * An HTML string that closes all necessary tags from the ancestor element (e.g.
+ */
+ outerContextEnd: string;
+ /**
+ * A string that opens all necessary tags between the ancestor and the beginning of the selection (e.g.
)
+ */
+ innerContextStart: string;
+ /**
+ * A string that closes all tags after the end of the selection to the ancestor (e.g.
)
+ */
+ innerContextEnd: string;
+ /**
+ * The HTML before the selected area begins (including line numbers)
+ */
+ previousHtml: string;
+ /**
+ * A HTML snippet that closes all open tags from previousHtml
+ */
+ previousHtmlEndSnippet: string;
+ /**
+ * The HTML after the selected area
+ */
+ followingHtml: string;
+ /**
+ * A HTML snippet that opens all HTML tags necessary to render "followingHtml"
+ */
+ followingHtmlStartSnippet: string;
+}
+
+/**
+ * An object specifying a range of line numbers.
+ */
+export interface LineRange {
+ /**
+ * The first line number to be included.
+ */
+ from: number;
+ /**
+ * The end line number.
+ * HINT: As this object is usually referring to actual line numbers, not lines,
+ * the line starting by `to` is not included in the extracted content anymore, only the text between `from` and `to`.
+ */
+ to: number;
+}
+
+/**
+ * Functionality regarding diffing, merging and extracting line ranges.
+ *
+ * ## Examples
+ *
+ * Cleaning up a string generated by CKEditor:
+ *
+ * ```ts
+ * this.diffService.removeDuplicateClassesInsertedByCkeditor(motion.text)
+ * ```
+ *
+ * Extracting a range specified by line numbers from a motion text:
+ *
+ * ```ts
+ * const lineLength = 80;
+ * const lineNumberedText = this.lineNumbering.insertLineNumbers('
A line
Another line
A list item
Yet another item
', lineLength);
+ * const extractFrom = 2;
+ * const extractUntil = 3;
+ * const extractedData = this.diffService.extractRangeByLineNumbers(lineNumberedText, extractFrom, extractUntil)
+ * ```
+ *
+ * Creating a valid HTML from such a extracted text, including line numbers:
+ *
+ * ```ts
+ * const extractedHtml = this.diffService.formatDiffWithLineNumbers(extractedData, lineLength, extractFrom);
+ * ```
+ *
+ * Creating the diff between two html strings:
+ *
+ * ```ts
+ * const before = '
Lorem ipsum dolor sit amet, sed diam voluptua. At2
';
+ * const diff = this.diffService.diff(before, after);
+ * ```ts
+ *
+ * Given a (line numbered) diff string, detect the line number range with changes:
+ *
+ * ```ts
+ * this.diffService.detectAffectedLineRange(diff);
+ * ```
+ *
+ * Given a diff'ed string, apply all changes to receive the new version of the text:
+ *
+ * ```ts
+ * const diffedHtml = '
Test Test 2 Another test Test 3
Test 4
';
+ * const newVersion = this.diffService.diffHtmlToFinalText(diffedHtml);
+ * ```
+ *
+ * Replace a line number range in a text by new text:
+ *
+ * ```ts
+ * const lineLength = 80;
+ * const lineNumberedText = this.lineNumbering.insertLineNumbers('
', 1, 2);
+ * ```
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class DiffService {
+ // @TODO Decide on a more sophisticated implementation
+ private diffCache = {
+ _cache: {},
+ get: (key: string): any => {
+ return this.diffCache._cache[key] === undefined ? null : this.diffCache._cache[key];
+ },
+ put: (key: string, val: any): void => {
+ this.diffCache._cache[key] = val;
+ }
+ };
+
+ /**
+ * Creates the DiffService.
+ *
+ * @param {LinenumberingService} lineNumberingService
+ */
+ public constructor(private readonly lineNumberingService: LinenumberingService) {}
+
+ /**
+ * Searches for the line breaking node within the given Document specified by the given lineNumber.
+ * This is performed by using a querySelector.
+ *
+ * @param {DocumentFragment} fragment
+ * @param {number} lineNumber
+ * @returns {Element}
+ */
+ public getLineNumberNode(fragment: DocumentFragment, lineNumber: number): Element {
+ return fragment.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber);
+ }
+
+ /**
+ * This returns the first line breaking node within the given node.
+ * If none is found, `null` is returned.
+ *
+ * @param {Node} node
+ * @returns {Element}
+ */
+ private getFirstLineNumberNode(node: Node): Element {
+ if (node.nodeType === TEXT_NODE) {
+ return null;
+ }
+ const element = node;
+ if (element.nodeName === 'OS-LINEBREAK') {
+ return element;
+ }
+ const found = element.querySelectorAll('OS-LINEBREAK');
+ if (found.length > 0) {
+ return found.item(0);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * This returns the last line breaking node within the given node.
+ * If none is found, `null` is returned.
+ *
+ * @param {Node} node
+ * @returns {Element}
+ */
+ private getLastLineNumberNode(node: Node): Element {
+ if (node.nodeType === TEXT_NODE) {
+ return null;
+ }
+ const element = node;
+ if (element.nodeName === 'OS-LINEBREAK') {
+ return element;
+ }
+ const found = element.querySelectorAll('OS-LINEBREAK');
+ if (found.length > 0) {
+ return found.item(found.length - 1);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Given a node, this method returns an array containing all parent elements of this node, recursively.
+ *
+ * @param {Node} node
+ * @returns {Node[]}
+ */
+ private getNodeContextTrace(node: Node): Node[] {
+ const context = [];
+ let currNode = node;
+ while (currNode) {
+ context.unshift(currNode);
+ currNode = currNode.parentNode;
+ }
+ return context;
+ }
+
+ /**
+ * This method checks if the given `child`-Node is the first non-empty child element of the given parent Node
+ * called `node`. Hence the name of this method.
+ *
+ * @param node
+ * @param child
+ */
+ private isFirstNonemptyChild(node: Node, child: Node): boolean {
+ for (let i = 0; i < node.childNodes.length; i++) {
+ if (node.childNodes[i] === child) {
+ return true;
+ }
+ if (node.childNodes[i].nodeType !== TEXT_NODE || node.childNodes[i].nodeValue.match(/\S/)) {
+ return false;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Adds elements like
+ * to a given fragment
+ *
+ * @param {DocumentFragment} fragment
+ */
+ public insertInternalLineMarkers(fragment: DocumentFragment): void {
+ if (fragment.querySelectorAll('OS-LINEBREAK').length > 0) {
+ // Prevent duplicate calls
+ return;
+ }
+ const lineNumbers = fragment.querySelectorAll('span.os-line-number');
+ let lineMarker,
+ maxLineNumber = 0;
+
+ lineNumbers.forEach((insertBefore: Node) => {
+ const lineNumberElement = insertBefore;
+ while (
+ insertBefore.parentNode.nodeType !== DOCUMENT_FRAGMENT_NODE &&
+ this.isFirstNonemptyChild(insertBefore.parentNode, insertBefore)
+ ) {
+ insertBefore = insertBefore.parentNode;
+ }
+ lineMarker = document.createElement('OS-LINEBREAK');
+ lineMarker.setAttribute('data-line-number', lineNumberElement.getAttribute('data-line-number'));
+ lineMarker.setAttribute('class', lineNumberElement.getAttribute('class'));
+ insertBefore.parentNode.insertBefore(lineMarker, insertBefore);
+ maxLineNumber = parseInt(lineNumberElement.getAttribute('data-line-number'), 10);
+ });
+
+ // Add one more "fake" line number at the end and beginning, so we can select the last line as well
+ lineMarker = document.createElement('OS-LINEBREAK');
+ lineMarker.setAttribute('data-line-number', (maxLineNumber + 1).toString(10));
+ lineMarker.setAttribute('class', 'os-line-number line-number-' + (maxLineNumber + 1).toString(10));
+ fragment.appendChild(lineMarker);
+
+ lineMarker = document.createElement('OS-LINEBREAK');
+ lineMarker.setAttribute('data-line-number', '0');
+ lineMarker.setAttribute('class', 'os-line-number line-number-0');
+ fragment.insertBefore(lineMarker, fragment.firstChild);
+ }
+
+ /**
+ * An OL element has a number of child LI nodes. Given a `descendantNode` that might be anywhere within
+ * the hierarchy of this OL element, this method returns the index (starting with 1) of the LI element
+ * that contains this node.
+ *
+ * @param olNode
+ * @param descendantNode
+ */
+ private isWithinNthLIOfOL(olNode: Element, descendantNode: Node): number {
+ let nthLIOfOL = null;
+ while (descendantNode.parentNode) {
+ if (descendantNode.parentNode === olNode) {
+ let lisBeforeOl = 0,
+ foundMe = false;
+ for (let i = 0; i < olNode.childNodes.length && !foundMe; i++) {
+ if (olNode.childNodes[i] === descendantNode) {
+ foundMe = true;
+ } else if (olNode.childNodes[i].nodeName === 'LI') {
+ lisBeforeOl++;
+ }
+ }
+ nthLIOfOL = lisBeforeOl + 1;
+ }
+ descendantNode = descendantNode.parentNode;
+ }
+ return nthLIOfOL;
+ }
+
+ /**
+ * Returns information about the common ancestors of two given nodes.
+ *
+ * @param {Node} node1
+ * @param {Node} node2
+ * @returns {CommonAncestorData}
+ */
+ public getCommonAncestor(node1: Node, node2: Node): CommonAncestorData {
+ const trace1 = this.getNodeContextTrace(node1),
+ trace2 = this.getNodeContextTrace(node2),
+ childTrace1 = [],
+ childTrace2 = [];
+ let commonAncestor = null,
+ commonIndex = null;
+
+ for (let i = 0; i < trace1.length && i < trace2.length; i++) {
+ if (trace1[i] === trace2[i]) {
+ commonAncestor = trace1[i];
+ commonIndex = i;
+ }
+ }
+ for (let i = commonIndex + 1; i < trace1.length; i++) {
+ childTrace1.push(trace1[i]);
+ }
+ for (let i = commonIndex + 1; i < trace2.length; i++) {
+ childTrace2.push(trace2[i]);
+ }
+ return {
+ commonAncestor: commonAncestor,
+ trace1: childTrace1,
+ trace2: childTrace2,
+ index: commonIndex
+ };
+ }
+
+ /**
+ * This converts a HTML Node element into a rendered HTML string.
+ *
+ * @param {Node} node
+ * @returns {string}
+ */
+ private serializeTag(node: Node): string {
+ if (node.nodeType !== ELEMENT_NODE) {
+ // Fragments are only placeholders and do not have an HTML representation
+ return '';
+ }
+ const element = node;
+ let html = '<' + element.nodeName;
+ for (let i = 0; i < element.attributes.length; i++) {
+ const attr = element.attributes[i];
+ if (attr.name !== 'os-li-number') {
+ html += ' ' + attr.name + '="' + attr.value + '"';
+ }
+ }
+ html += '>';
+ return html;
+ }
+
+ /**
+ * This converts the given HTML string into a DOM tree contained by a DocumentFragment, which is reqturned.
+ *
+ * @param {string} html
+ * @return {DocumentFragment}
+ */
+ public htmlToFragment(html: string): DocumentFragment {
+ const fragment = document.createDocumentFragment(),
+ div = document.createElement('DIV');
+ div.innerHTML = html;
+ while (div.childElementCount) {
+ const child = div.childNodes[0];
+ div.removeChild(child);
+ fragment.appendChild(child);
+ }
+ return fragment;
+ }
+
+ /**
+ * This performs HTML normalization to prevent the Diff-Algorithm from detecting changes when there are actually
+ * none. Common problems covered by this method are differently ordered Attributes of HTML elements or HTML-encoded
+ * special characters.
+ * Unfortunately, the conversion of HTML-encoded characters to the actual characters is done by a lookup-table for
+ * now, as we haven't figured out a way to decode them automatically.
+ *
+ * @param {string} html
+ * @returns {string}
+ * @private
+ */
+ public normalizeHtmlForDiff(html: string): string {
+ // Convert all HTML tags to uppercase, but leave the values of attributes unchanged
+ // All attributes and CSS class names are sorted alphabetically
+ // If an attribute is empty, it is removed
+ html = html.replace(
+ /<(\/?[a-z]*)( [^>]*)?>/gi,
+ (_fullHtml: string, tag: string, attributes: string): string => {
+ const tagNormalized = tag.toUpperCase();
+ if (attributes === undefined) {
+ attributes = '';
+ }
+ const attributesList = [],
+ attributesMatcher = /( [^"'=]*)(= *((["'])(.*?)\4))?/gi;
+ let match;
+ do {
+ match = attributesMatcher.exec(attributes);
+ if (match) {
+ let attrNormalized = match[1].toUpperCase(),
+ attrValue = match[5];
+ if (match[2] !== undefined) {
+ if (attrNormalized === ' CLASS') {
+ attrValue = attrValue
+ .split(' ')
+ .sort()
+ .join(' ')
+ .replace(/^\s+/, '')
+ .replace(/\s+$/, '');
+ }
+ attrNormalized += '=' + match[4] + attrValue + match[4];
+ }
+ if (attrValue !== '') {
+ attributesList.push(attrNormalized);
+ }
+ }
+ } while (match);
+ attributes = attributesList.sort().join('');
+ return '<' + tagNormalized + attributes + '>';
+ }
+ );
+
+ const entities = {
+ ' ': ' ',
+ '–': '-',
+ 'ä': 'ä',
+ 'ö': 'ö',
+ 'ü': 'ü',
+ 'Ä': 'Ä',
+ 'Ö': 'Ö',
+ 'Ü': 'Ü',
+ 'ß': 'ß',
+ '„': '„',
+ '“': '“',
+ '•': '•',
+ '§': '§',
+ 'é': 'é',
+ '€': '€'
+ };
+
+ html = html
+ .replace(/\s+<\/P>/gi, '')
+ .replace(/\s+<\/DIV>/gi, '')
+ .replace(/\s+<\/LI>/gi, '
');
+ html = html.replace(/\s+
/gi, '
').replace(/<\/LI>\s+/gi, '
');
+ html = html.replace(/\u00A0/g, ' ');
+ html = html.replace(/\u2013/g, '-');
+ Object.keys(entities).forEach(ent => {
+ html = html.replace(new RegExp(ent, 'g'), entities[ent]);
+ });
+
+ // Newline characters: after closing block-level-elements, but not after BR (which is inline)
+ html = html.replace(/( )\n/gi, '$1');
+ html = html.replace(/[ \n\t]+/gi, ' ');
+ html = html.replace(/(<\/(div|p|ul|li|blockquote>)>) /gi, '$1\n');
+
+ return html;
+ }
+
+ /**
+ * Get all the siblings of the given node _after_ this node, in the order as they appear in the DOM tree.
+ *
+ * @param {Node} node
+ * @returns {Node[]}
+ */
+ private getAllNextSiblings(node: Node): Node[] {
+ const nodes: Node[] = [];
+ while (node.nextSibling) {
+ nodes.push(node.nextSibling);
+ node = node.nextSibling;
+ }
+ return nodes;
+ }
+
+ /**
+ * Get all the siblings of the given node _before_ this node,
+ * with the one closest to the given node first (=> reversed order in regard to the DOM tree order)
+ *
+ * @param {Node} node
+ * @returns {Node[]}
+ */
+ private getAllPrevSiblingsReversed(node: Node): Node[] {
+ const nodes = [];
+ while (node.previousSibling) {
+ nodes.push(node.previousSibling);
+ node = node.previousSibling;
+ }
+ return nodes;
+ }
+
+ /**
+ * Given two strings, this method tries to guess if `htmlNew` can be produced from `htmlOld` by inserting
+ * or deleting text, or if both is necessary (replac)
+ *
+ * @param {string} htmlOld
+ * @param {string} htmlNew
+ * @returns {number}
+ */
+ public detectReplacementType(htmlOld: string, htmlNew: string): ModificationType {
+ htmlOld = this.normalizeHtmlForDiff(htmlOld);
+ htmlNew = this.normalizeHtmlForDiff(htmlNew);
+
+ if (htmlOld === htmlNew) {
+ return ModificationType.TYPE_REPLACEMENT;
+ }
+
+ let i, foundDiff;
+ for (i = 0, foundDiff = false; i < htmlOld.length && i < htmlNew.length && foundDiff === false; i++) {
+ if (htmlOld[i] !== htmlNew[i]) {
+ foundDiff = true;
+ }
+ }
+
+ const remainderOld = htmlOld.substr(i - 1),
+ remainderNew = htmlNew.substr(i - 1);
+ let type = ModificationType.TYPE_REPLACEMENT;
+
+ if (remainderOld.length > remainderNew.length) {
+ if (remainderOld.substr(remainderOld.length - remainderNew.length) === remainderNew) {
+ type = ModificationType.TYPE_DELETION;
+ }
+ } else if (remainderOld.length < remainderNew.length) {
+ if (remainderNew.substr(remainderNew.length - remainderOld.length) === remainderOld) {
+ type = ModificationType.TYPE_INSERTION;
+ }
+ }
+
+ return type;
+ }
+
+ /**
+ * This method adds a CSS class name to a given node.
+ *
+ * @param {Node} node
+ * @param {string} className
+ */
+ public addCSSClass(node: Node, className: string): void {
+ if (node.nodeType !== ELEMENT_NODE) {
+ return;
+ }
+ const element = node;
+ const classesStr = element.getAttribute('class');
+ const classes = classesStr ? classesStr.split(' ') : [];
+ if (classes.indexOf(className) === -1) {
+ classes.push(className);
+ }
+ element.setAttribute('class', classes.join(' '));
+ }
+
+ /**
+ * This method removes a CSS class name from a given node.
+ *
+ * @param {Node} node
+ * @param {string} className
+ */
+ public removeCSSClass(node: Node, className: string): void {
+ if (node.nodeType !== ELEMENT_NODE) {
+ return;
+ }
+ const element = node;
+ const classesStr = element.getAttribute('class');
+ const newClasses = [];
+ const classes = classesStr ? classesStr.split(' ') : [];
+ for (let i = 0; i < classes.length; i++) {
+ if (classes[i] !== className) {
+ newClasses.push(classes[i]);
+ }
+ }
+ if (newClasses.length === 0) {
+ element.removeAttribute('class');
+ } else {
+ element.setAttribute('class', newClasses.join(' '));
+ }
+ }
+
+ /**
+ * Adapted from http://ejohn.org/projects/javascript-diff-algorithm/
+ * by John Resig, MIT License
+ * @param {array} oldArr
+ * @param {array} newArr
+ * @returns {object}
+ */
+ private diffArrays(oldArr: any, newArr: any): any {
+ const ns = {},
+ os = {};
+ let i;
+
+ for (i = 0; i < newArr.length; i++) {
+ if (ns[newArr[i]] === undefined) {
+ ns[newArr[i]] = { rows: [], o: null };
+ }
+ ns[newArr[i]].rows.push(i);
+ }
+
+ for (i = 0; i < oldArr.length; i++) {
+ if (os[oldArr[i]] === undefined) {
+ os[oldArr[i]] = { rows: [], n: null };
+ }
+ os[oldArr[i]].rows.push(i);
+ }
+
+ for (i in ns) {
+ if (ns[i].rows.length === 1 && typeof os[i] !== 'undefined' && os[i].rows.length === 1) {
+ newArr[ns[i].rows[0]] = { text: newArr[ns[i].rows[0]], row: os[i].rows[0] };
+ oldArr[os[i].rows[0]] = { text: oldArr[os[i].rows[0]], row: ns[i].rows[0] };
+ }
+ }
+
+ for (i = 0; i < newArr.length - 1; i++) {
+ if (
+ newArr[i].text !== null &&
+ newArr[i + 1].text === undefined &&
+ newArr[i].row + 1 < oldArr.length &&
+ oldArr[newArr[i].row + 1].text === undefined &&
+ newArr[i + 1] === oldArr[newArr[i].row + 1]
+ ) {
+ newArr[i + 1] = { text: newArr[i + 1], row: newArr[i].row + 1 };
+ oldArr[newArr[i].row + 1] = { text: oldArr[newArr[i].row + 1], row: i + 1 };
+ }
+ }
+
+ for (i = newArr.length - 1; i > 0; i--) {
+ if (
+ newArr[i].text !== null &&
+ newArr[i - 1].text === undefined &&
+ newArr[i].row > 0 &&
+ oldArr[newArr[i].row - 1].text === undefined &&
+ newArr[i - 1] === oldArr[newArr[i].row - 1]
+ ) {
+ newArr[i - 1] = { text: newArr[i - 1], row: newArr[i].row - 1 };
+ oldArr[newArr[i].row - 1] = { text: oldArr[newArr[i].row - 1], row: i - 1 };
+ }
+ }
+
+ return { o: oldArr, n: newArr };
+ }
+
+ /**
+ * This method splits a string into an array of strings, such as that it can be used by the diff method.
+ * Mainly it tries to split it into single words, but prevents HTML tags from being split into different elements.
+ *
+ * @param {string} str
+ * @returns {string[]}
+ */
+ private tokenizeHtml(str: string): string[] {
+ const splitArrayEntriesEmbedSeparator = (arrIn: string[], by: string, prepend: boolean): string[] => {
+ const newArr = [];
+ for (let i = 0; i < arrIn.length; i++) {
+ if (arrIn[i][0] === '<' && (by === ' ' || by === '\n')) {
+ // Don't split HTML tags
+ newArr.push(arrIn[i]);
+ continue;
+ }
+
+ const parts = arrIn[i].split(by);
+ if (parts.length === 1) {
+ newArr.push(arrIn[i]);
+ } else {
+ let j;
+ if (prepend) {
+ if (parts[0] !== '') {
+ newArr.push(parts[0]);
+ }
+ for (j = 1; j < parts.length; j++) {
+ newArr.push(by + parts[j]);
+ }
+ } else {
+ for (j = 0; j < parts.length - 1; j++) {
+ newArr.push(parts[j] + by);
+ }
+ if (parts[parts.length - 1] !== '') {
+ newArr.push(parts[parts.length - 1]);
+ }
+ }
+ }
+ }
+ return newArr;
+ };
+ const splitArrayEntriesSplitSeparator = (arrIn: string[], by: string): string[] => {
+ const newArr = [];
+ for (let i = 0; i < arrIn.length; i++) {
+ if (arrIn[i][0] === '<') {
+ newArr.push(arrIn[i]);
+ continue;
+ }
+ const parts = arrIn[i].split(by);
+ for (let j = 0; j < parts.length; j++) {
+ if (j > 0) {
+ newArr.push(by);
+ }
+ newArr.push(parts[j]);
+ }
+ }
+ return newArr;
+ };
+ let arr = splitArrayEntriesEmbedSeparator([str], '<', true);
+ arr = splitArrayEntriesEmbedSeparator(arr, '>', false);
+ arr = splitArrayEntriesSplitSeparator(arr, ' ');
+ arr = splitArrayEntriesSplitSeparator(arr, '.');
+ arr = splitArrayEntriesSplitSeparator(arr, ',');
+ arr = splitArrayEntriesSplitSeparator(arr, '!');
+ arr = splitArrayEntriesSplitSeparator(arr, '-');
+ arr = splitArrayEntriesEmbedSeparator(arr, '\n', false);
+
+ const arrWithoutEmpties = [];
+ for (let i = 0; i < arr.length; i++) {
+ if (arr[i] !== '') {
+ arrWithoutEmpties.push(arr[i]);
+ }
+ }
+
+ return arrWithoutEmpties;
+ }
+
+ /**
+ * Given two strings, this method generates a consolidated new string that indicates the operations necessary
+ * to get from `oldStr` to `newStr` by ... and ...-Tags
+ *
+ * @param {string} oldStr
+ * @param {string} newStr
+ * @returns {string}
+ */
+ private diffString(oldStr: string, newStr: string): string {
+ oldStr = this.normalizeHtmlForDiff(oldStr.replace(/\s+$/, '').replace(/^\s+/, ''));
+ newStr = this.normalizeHtmlForDiff(newStr.replace(/\s+$/, '').replace(/^\s+/, ''));
+
+ const out = this.diffArrays(this.tokenizeHtml(oldStr), this.tokenizeHtml(newStr));
+
+ // This fixes the problem tested by "does not lose words when changes are moved X-wise"
+ let lastRow = 0;
+ for (let z = 0; z < out.n.length; z++) {
+ if (out.n[z].row && out.n[z].row > lastRow) {
+ lastRow = out.n[z].row;
+ }
+ if (out.n[z].row && out.n[z].row < lastRow) {
+ out.o[out.n[z].row] = out.o[out.n[z].row].text;
+ out.n[z] = out.n[z].text;
+ }
+ }
+
+ let str = '';
+ let i;
+
+ if (out.n.length === 0) {
+ for (i = 0; i < out.o.length; i++) {
+ str += '' + out.o[i] + '';
+ }
+ } else {
+ if (out.n[0].text === undefined) {
+ for (let k = 0; k < out.o.length && out.o[k].text === undefined; k++) {
+ str += '' + out.o[k] + '';
+ }
+ }
+
+ let currOldRow = 0;
+ for (i = 0; i < out.n.length; i++) {
+ if (out.n[i].text === undefined) {
+ if (out.n[i] !== '') {
+ str += '' + out.n[i] + '';
+ }
+ } else if (out.n[i].row < currOldRow) {
+ str += '' + out.n[i].text + '';
+ } else {
+ let pre = '';
+
+ if (i + 1 < out.n.length && out.n[i + 1].row !== undefined && out.n[i + 1].row > out.n[i].row + 1) {
+ for (let n = out.n[i].row + 1; n < out.n[i + 1].row; n++) {
+ if (out.o[n].text === undefined) {
+ pre += '' + out.o[n] + '';
+ } else {
+ pre += '' + out.o[n].text + '';
+ }
+ }
+ } else {
+ for (let j = out.n[i].row + 1; j < out.o.length && out.o[j].text === undefined; j++) {
+ pre += '' + out.o[j] + '';
+ }
+ }
+ str += out.n[i].text + pre;
+
+ currOldRow = out.n[i].row;
+ }
+ }
+ }
+
+ return str
+ .replace(/^\s+/g, '')
+ .replace(/\s+$/g, '')
+ .replace(/ {2,}/g, ' ');
+ }
+
+ /**
+ * This checks if this string is valid inline HTML.
+ * It does so by leveraging the browser's auto-correction mechanism and coun the number of "<"s (opening and closing
+ * HTML tags) of the original and the cleaned-up string.
+ * This is mainly helpful to decide if a given string can be put into ... or ...-Tags without
+ * producing broken HTML.
+ *
+ * @param {string} html
+ * @return {boolean}
+ * @private
+ */
+ private isValidInlineHtml(html: string): boolean {
+ // If there are no HTML tags, we assume it's valid and skip further checks
+ if (!html.match(/<[^>]*>/)) {
+ return true;
+ }
+
+ // We check if this is a valid HTML that closes all its tags again using the innerHTML-Hack to correct
+ // the string and check if the number of HTML tags changes by this
+ const doc = document.createElement('div');
+ doc.innerHTML = html;
+ const tagsBefore = (html.match(/ it was not valid
+ return false;
+ }
+
+ // If there is any block element inside, we consider it as broken, as this string will be displayed
+ // inside of / tags
+ if (html.match(/<(div|p|ul|li|blockquote)\W/i)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * This detects if a given string contains broken HTML. This can happen when the Diff accidentally produces
+ * wrongly nested HTML tags.
+ *
+ * @param {string} html
+ * @returns {boolean}
+ * @private
+ */
+ private diffDetectBrokenDiffHtml(html: string): boolean {
+ // If other HTML tags are contained within INS/DEL (e.g. "Test"), let's better be cautious
+ // The "!!(found=...)"-construction is only used to make jshint happy :)
+ const findDel = /(.*?)<\/del>/gi,
+ findIns = /(.*?)<\/ins>/gi;
+ let found, inner;
+ while (!!(found = findDel.exec(html))) {
+ inner = found[1].replace(/ ]*>/gi, '');
+ if (inner.match(/<[^>]*>/)) {
+ return true;
+ }
+ }
+ while (!!(found = findIns.exec(html))) {
+ inner = found[1].replace(/ ]*>/gi, '');
+ if (!this.isValidInlineHtml(inner)) {
+ return true;
+ }
+ }
+
+ // If non of the conditions up to now is met, we consider the diff as being sane
+ return false;
+ }
+
+ /**
+ * Adds a CSS class to the first opening HTML tag within the given string.
+ *
+ * @param {string} html
+ * @param {string} className
+ * @returns {string}
+ */
+ public addCSSClassToFirstTag(html: string, className: string): string {
+ return html.replace(
+ /<[a-z][^>]*>/i,
+ (match: string): string => {
+ if (match.match(/class=["'][a-z0-9 _-]*["']/i)) {
+ return match.replace(
+ /class=["']([a-z0-9 _-]*)["']/i,
+ (match2: string, previousClasses: string): string => {
+ return 'class="' + previousClasses + ' ' + className + '"';
+ }
+ );
+ } else {
+ return match.substring(0, match.length - 1) + ' class="' + className + '">';
+ }
+ }
+ );
+ }
+
+ /**
+ * Adds a CSS class to the last opening HTML tag within the given string.
+ *
+ * @param {string} html
+ * @param {string} className
+ * @returns {string}
+ */
+ public addClassToLastNode(html: string, className: string): string {
+ const node = document.createElement('div');
+ node.innerHTML = html;
+ let foundLast = false;
+ for (let i = node.childNodes.length - 1; i >= 0 && !foundLast; i--) {
+ if (node.childNodes[i].nodeType === ELEMENT_NODE) {
+ const childElement = node.childNodes[i];
+ let classes = [];
+ if (childElement.getAttribute('class')) {
+ classes = childElement.getAttribute('class').split(' ');
+ }
+ classes.push(className);
+ childElement.setAttribute(
+ 'class',
+ classes
+ .sort()
+ .join(' ')
+ .replace(/^\s+/, '')
+ .replace(/\s+$/, '')
+ );
+ foundLast = true;
+ }
+ }
+ return node.innerHTML;
+ }
+
+ /**
+ * This function removes color-Attributes from the styles of this node or a descendant,
+ * as they interfer with the green/red color in HTML and PDF
+ *
+ * For the moment, it is sufficient to do this only in paragraph diff mode, as we fall back to this mode anyway
+ * once we encounter SPANs or other tags inside of INS/DEL-tags
+ *
+ * @param {Element} node
+ * @private
+ */
+ private removeColorStyles(node: Element): void {
+ const styles = node.getAttribute('style');
+ if (styles && styles.indexOf('color') > -1) {
+ const stylesNew = [];
+ styles.split(';').forEach(
+ (style: string): void => {
+ if (!style.match(/^\s*color\s*:/i)) {
+ stylesNew.push(style);
+ }
+ }
+ );
+ if (stylesNew.join(';') === '') {
+ node.removeAttribute('style');
+ } else {
+ node.setAttribute('style', stylesNew.join(';'));
+ }
+ }
+ for (let i = 0; i < node.childNodes.length; i++) {
+ if (node.childNodes[i].nodeType === ELEMENT_NODE) {
+ this.removeColorStyles(node.childNodes[i]);
+ }
+ }
+ }
+
+ /**
+ * This fixes a very specific, really weird bug that is tested in the test case "does not a change in a very specific case".
+ *
+ * @param {string}diffStr
+ * @return {string}
+ */
+ private fixWrongChangeDetection(diffStr: string): string {
+ if (diffStr.indexOf('') === -1 || diffStr.indexOf('') === -1) {
+ return diffStr;
+ }
+
+ const findDelGroupFinder = /(?:.*?<\/del>)+/gi;
+ let found,
+ returnStr = diffStr;
+
+ while (!!(found = findDelGroupFinder.exec(diffStr))) {
+ const del = found[0],
+ split = returnStr.split(del);
+
+ const findInsGroupFinder = /^(?:.*?<\/ins>)+/gi,
+ foundIns = findInsGroupFinder.exec(split[1]);
+ if (foundIns) {
+ const ins = foundIns[0];
+
+ let delShortened = del
+ .replace(
+ /(( <\/del>)?(]+os-line-number[^>]+?>)(\s|<\/?del>)*<\/span>)<\/del>/gi,
+ ''
+ )
+ .replace(/<\/del>/g, '');
+ const insConv = ins
+ .replace(//g, '')
+ .replace(/<\/ins>/g, '')
+ .replace(/<\/del>/g, '');
+ if (delShortened.indexOf(insConv) !== -1) {
+ delShortened = delShortened.replace(insConv, '');
+ if (delShortened === '') {
+ returnStr = returnStr.replace(del + ins, del.replace(//g, '').replace(/<\/del>/g, ''));
+ }
+ }
+ }
+ }
+ return returnStr;
+ }
+
+ /**
+ * Converts a given HTML node into HTML string and optionally strips line number nodes from it.
+ *
+ * @param {Node} node
+ * @param {boolean} stripLineNumbers
+ * @returns {string}
+ */
+ private serializeDom(node: Node, stripLineNumbers: boolean): string {
+ if (node.nodeType === TEXT_NODE) {
+ return node.nodeValue.replace(//g, '>');
+ }
+ if (
+ stripLineNumbers &&
+ (this.lineNumberingService.isOsLineNumberNode(node) || this.lineNumberingService.isOsLineBreakNode(node))
+ ) {
+ return '';
+ }
+ if (node.nodeName === 'OS-LINEBREAK') {
+ return '';
+ }
+ if (node.nodeName === 'BR') {
+ const element = node;
+ let br = ' ';
+ }
+
+ let html = this.serializeTag(node);
+ for (let i = 0; i < node.childNodes.length; i++) {
+ if (node.childNodes[i].nodeType === TEXT_NODE) {
+ html += node.childNodes[i].nodeValue
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+ } else if (
+ !stripLineNumbers ||
+ (!this.lineNumberingService.isOsLineNumberNode(node.childNodes[i]) &&
+ !this.lineNumberingService.isOsLineBreakNode(node.childNodes[i]))
+ ) {
+ html += this.serializeDom(node.childNodes[i], stripLineNumbers);
+ }
+ }
+ if (node.nodeType !== DOCUMENT_FRAGMENT_NODE) {
+ html += '' + node.nodeName + '>';
+ }
+
+ return html;
+ }
+
+ /**
+ * When a
with a os-split-before-class (set by extractRangeByLineNumbers) is edited when creating a
+ * change recommendation and is split again in CKEditor, the second list items also gets that class.
+ * This is not correct however, as the second one actually is a new list item. So we need to remove it again.
+ *
+ * @param {string} html
+ * @returns {string}
+ */
+ public removeDuplicateClassesInsertedByCkeditor(html: string): string {
+ const fragment = this.htmlToFragment(html);
+ const items = fragment.querySelectorAll('li.os-split-before');
+ for (let i = 0; i < items.length; i++) {
+ if (!this.isFirstNonemptyChild(items[i].parentNode, items[i])) {
+ this.removeCSSClass(items[i], 'os-split-before');
+ }
+ }
+ return this.serializeDom(fragment, false);
+ }
+
+ /**
+ * Given a DOM tree and a specific node within that tree, this method returns the HTML string from the beginning
+ * of this tree up to this node.
+ * The returned string in itself is not renderable, as it stops in the middle of the complete HTML, with opened tags.
+ *
+ * Implementation hint: the first element of "toChildTrace" array needs to be a child element of "node"
+ * @param {Node} node
+ * @param {Node[]} toChildTrace
+ * @param {boolean} stripLineNumbers
+ * @returns {string}
+ */
+ public serializePartialDomToChild(node: Node, toChildTrace: Node[], stripLineNumbers: boolean): string {
+ if (this.lineNumberingService.isOsLineNumberNode(node) || this.lineNumberingService.isOsLineBreakNode(node)) {
+ return '';
+ }
+ if (node.nodeName === 'OS-LINEBREAK') {
+ return '';
+ }
+
+ let html = this.serializeTag(node),
+ found = false;
+
+ for (let i = 0; i < node.childNodes.length && !found; i++) {
+ if (node.childNodes[i] === toChildTrace[0]) {
+ found = true;
+ const childElement = node.childNodes[i];
+ const remainingTrace = toChildTrace;
+ remainingTrace.shift();
+ if (!this.lineNumberingService.isOsLineNumberNode(childElement)) {
+ html += this.serializePartialDomToChild(childElement, remainingTrace, stripLineNumbers);
+ }
+ } else if (node.childNodes[i].nodeType === TEXT_NODE) {
+ html += node.childNodes[i].nodeValue;
+ } else {
+ const childElement = node.childNodes[i];
+ if (
+ !stripLineNumbers ||
+ (!this.lineNumberingService.isOsLineNumberNode(childElement) &&
+ !this.lineNumberingService.isOsLineBreakNode(childElement))
+ ) {
+ html += this.serializeDom(childElement, stripLineNumbers);
+ }
+ }
+ }
+ if (!found) {
+ throw new Error('Inconsistency or invalid call of this function detected (to)');
+ }
+ return html;
+ }
+
+ /**
+ * Given a DOM tree and a specific node within that tree, this method returns the HTML string beginning after this
+ * node to the end of the tree.
+ * The returned string in itself is not renderable, as it starts in the middle of the complete HTML, with opened tags.
+ *
+ * Implementation hint: the first element of "fromChildTrace" array needs to be a child element of "node"
+ * @param {Node} node
+ * @param {Node[]} fromChildTrace
+ * @param {boolean} stripLineNumbers
+ * @returns {string}
+ */
+ public serializePartialDomFromChild(node: Node, fromChildTrace: Node[], stripLineNumbers: boolean): string {
+ if (this.lineNumberingService.isOsLineNumberNode(node) || this.lineNumberingService.isOsLineBreakNode(node)) {
+ return '';
+ }
+ if (node.nodeName === 'OS-LINEBREAK') {
+ return '';
+ }
+
+ let html = '',
+ found = false;
+ for (let i = 0; i < node.childNodes.length; i++) {
+ if (node.childNodes[i] === fromChildTrace[0]) {
+ found = true;
+ const childElement = node.childNodes[i];
+ const remainingTrace = fromChildTrace;
+ remainingTrace.shift();
+ if (!this.lineNumberingService.isOsLineNumberNode(childElement)) {
+ html += this.serializePartialDomFromChild(childElement, remainingTrace, stripLineNumbers);
+ }
+ } else if (found) {
+ if (node.childNodes[i].nodeType === TEXT_NODE) {
+ html += node.childNodes[i].nodeValue;
+ } else {
+ const childElement = node.childNodes[i];
+ if (
+ !stripLineNumbers ||
+ (!this.lineNumberingService.isOsLineNumberNode(childElement) &&
+ !this.lineNumberingService.isOsLineBreakNode(childElement))
+ ) {
+ html += this.serializeDom(childElement, stripLineNumbers);
+ }
+ }
+ }
+ }
+ if (!found) {
+ throw new Error('Inconsistency or invalid call of this function detected (from)');
+ }
+ if (node.nodeType !== DOCUMENT_FRAGMENT_NODE) {
+ html += '' + node.nodeName + '>';
+ }
+ return html;
+ }
+
+ /**
+ * Returns the HTML snippet between two given line numbers.
+ * extractRangeByLineNumbers
+ * Hint:
+ * - The last line (toLine) is not included anymore, as the number refers to the line breaking element at the end of the line
+ * - if toLine === null, then everything from fromLine to the end of the fragment is returned
+ *
+ * In addition to the HTML snippet, additional information is provided regarding the most specific DOM element
+ * that contains the whole section specified by the line numbers (like a P-element if only one paragraph is selected
+ * or the most outer DIV, if multiple sections selected).
+ *
+ * This additional information is meant to render the snippet correctly without producing broken HTML
+ *
+ * In some cases, the returned HTML tags receive additional CSS classes, providing information both for
+ * rendering it and for merging it again correctly.
+ * - os-split-*: These classes are set for all HTML Tags that have been split into two by this process,
+ * e.g. if the fromLine- or toLine-line-break was somewhere in the middle of this tag.
+ * If a tag is split, the first one receives "os-split-after", and the second one "os-split-before".
+ * For example, for the following string
Line 1 Line 2 Line 3
:
+ * - extracting line 1 to 2 results in
Line 1
+ * - extracting line 2 to 3 results in
Line 2
+ * - extracting line 3 to null/4 results in
Line 3
+ *
+ * @param {string} htmlIn
+ * @param {number} fromLine
+ * @param {number} toLine
+ * @returns {ExtractedContent}
+ */
+ public extractRangeByLineNumbers(htmlIn: string, fromLine: number, toLine: number): ExtractedContent {
+ if (typeof htmlIn !== 'string') {
+ throw new Error('Invalid call - extractRangeByLineNumbers expects a string as first argument');
+ }
+
+ const cacheKey = fromLine + '-' + toLine + '-' + this.lineNumberingService.djb2hash(htmlIn),
+ cached = this.diffCache.get(cacheKey);
+
+ if (cached) {
+ return cached;
+ }
+
+ const fragment = this.htmlToFragment(htmlIn);
+
+ this.insertInternalLineMarkers(fragment);
+ if (toLine === null) {
+ const internalLineMarkers = fragment.querySelectorAll('OS-LINEBREAK'),
+ lastMarker = internalLineMarkers[internalLineMarkers.length - 1];
+ toLine = parseInt(lastMarker.getAttribute('data-line-number'), 10);
+ }
+
+ const fromLineNode = this.getLineNumberNode(fragment, fromLine),
+ toLineNode = toLine ? this.getLineNumberNode(fragment, toLine) : null,
+ ancestorData = this.getCommonAncestor(fromLineNode, toLineNode);
+
+ const fromChildTraceRel = ancestorData.trace1,
+ fromChildTraceAbs = this.getNodeContextTrace(fromLineNode),
+ toChildTraceRel = ancestorData.trace2,
+ toChildTraceAbs = this.getNodeContextTrace(toLineNode),
+ ancestor = ancestorData.commonAncestor;
+ let htmlOut = '',
+ outerContextStart = '',
+ outerContextEnd = '',
+ innerContextStart = '',
+ innerContextEnd = '',
+ previousHtmlEndSnippet = '',
+ followingHtmlStartSnippet = '',
+ fakeOl,
+ offset;
+
+ fromChildTraceAbs.shift();
+ const previousHtml = this.serializePartialDomToChild(fragment, fromChildTraceAbs, false);
+ toChildTraceAbs.shift();
+ const followingHtml = this.serializePartialDomFromChild(fragment, toChildTraceAbs, false);
+
+ let currNode: Node = fromLineNode,
+ isSplit = false;
+ while (currNode.parentNode) {
+ if (!this.isFirstNonemptyChild(currNode.parentNode, currNode)) {
+ isSplit = true;
+ }
+ if (isSplit) {
+ this.addCSSClass(currNode.parentNode, 'os-split-before');
+ }
+ if (currNode.nodeName !== 'OS-LINEBREAK') {
+ previousHtmlEndSnippet += '' + currNode.nodeName + '>';
+ }
+ currNode = currNode.parentNode;
+ }
+
+ currNode = toLineNode;
+ isSplit = false;
+ while (currNode.parentNode) {
+ if (!this.isFirstNonemptyChild(currNode.parentNode, currNode)) {
+ isSplit = true;
+ }
+ if (isSplit) {
+ this.addCSSClass(currNode.parentNode, 'os-split-after');
+ }
+ if (currNode.parentNode.nodeName === 'OL') {
+ const parentElement = currNode.parentNode;
+ fakeOl = parentElement.cloneNode(false);
+ offset = parentElement.getAttribute('start')
+ ? parseInt(parentElement.getAttribute('start'), 10) - 1
+ : 0;
+ fakeOl.setAttribute('start', (this.isWithinNthLIOfOL(parentElement, toLineNode) + offset).toString());
+ followingHtmlStartSnippet = this.serializeTag(fakeOl) + followingHtmlStartSnippet;
+ } else {
+ followingHtmlStartSnippet = this.serializeTag(currNode.parentNode) + followingHtmlStartSnippet;
+ }
+ currNode = currNode.parentNode;
+ }
+
+ let found = false;
+ isSplit = false;
+ for (let i = 0; i < fromChildTraceRel.length && !found; i++) {
+ if (fromChildTraceRel[i].nodeName === 'OS-LINEBREAK') {
+ found = true;
+ } else {
+ if (!this.isFirstNonemptyChild(fromChildTraceRel[i], fromChildTraceRel[i + 1])) {
+ isSplit = true;
+ }
+ if (fromChildTraceRel[i].nodeName === 'OL') {
+ const element = fromChildTraceRel[i];
+ fakeOl = element.cloneNode(false);
+ offset = element.getAttribute('start') ? parseInt(element.getAttribute('start'), 10) - 1 : 0;
+ fakeOl.setAttribute('start', (offset + this.isWithinNthLIOfOL(element, fromLineNode)).toString());
+ innerContextStart += this.serializeTag(fakeOl);
+ } else {
+ if (i < fromChildTraceRel.length - 1 && isSplit) {
+ this.addCSSClass(fromChildTraceRel[i], 'os-split-before');
+ }
+ innerContextStart += this.serializeTag(fromChildTraceRel[i]);
+ }
+ }
+ }
+ found = false;
+ for (let i = 0; i < toChildTraceRel.length && !found; i++) {
+ if (toChildTraceRel[i].nodeName === 'OS-LINEBREAK') {
+ found = true;
+ } else {
+ innerContextEnd = '' + toChildTraceRel[i].nodeName + '>' + innerContextEnd;
+ }
+ }
+
+ found = false;
+ for (let i = 0; i < ancestor.childNodes.length; i++) {
+ if (ancestor.childNodes[i] === fromChildTraceRel[0]) {
+ found = true;
+ fromChildTraceRel.shift();
+ htmlOut += this.serializePartialDomFromChild(ancestor.childNodes[i], fromChildTraceRel, true);
+ } else if (ancestor.childNodes[i] === toChildTraceRel[0]) {
+ found = false;
+ toChildTraceRel.shift();
+ htmlOut += this.serializePartialDomToChild(ancestor.childNodes[i], toChildTraceRel, true);
+ } else if (found === true) {
+ htmlOut += this.serializeDom(ancestor.childNodes[i], true);
+ }
+ }
+
+ currNode = ancestor;
+ while (currNode.parentNode) {
+ if (currNode.nodeName === 'OL') {
+ const currElement = currNode;
+ fakeOl = currElement.cloneNode(false);
+ offset = currElement.getAttribute('start') ? parseInt(currElement.getAttribute('start'), 10) - 1 : 0;
+ fakeOl.setAttribute('start', (this.isWithinNthLIOfOL(currElement, fromLineNode) + offset).toString());
+ outerContextStart = this.serializeTag(fakeOl) + outerContextStart;
+ } else {
+ outerContextStart = this.serializeTag(currNode) + outerContextStart;
+ }
+ outerContextEnd += '' + currNode.nodeName + '>';
+ currNode = currNode.parentNode;
+ }
+
+ const ret = {
+ html: htmlOut,
+ ancestor: ancestor,
+ outerContextStart: outerContextStart,
+ outerContextEnd: outerContextEnd,
+ innerContextStart: innerContextStart,
+ innerContextEnd: innerContextEnd,
+ previousHtml: previousHtml,
+ previousHtmlEndSnippet: previousHtmlEndSnippet,
+ followingHtml: followingHtml,
+ followingHtmlStartSnippet: followingHtmlStartSnippet
+ };
+
+ this.diffCache.put(cacheKey, ret);
+ return ret;
+ }
+
+ /**
+ * Convenience method that takes the html-attribute from an extractRangeByLineNumbers()-method,
+ * wraps it with the context and adds line numbers.
+ *
+ * @param {ExtractedContent} diff
+ * @param {number} lineLength
+ * @param {number} firstLine
+ */
+ public formatDiffWithLineNumbers(diff: ExtractedContent, lineLength: number, firstLine: number): string {
+ let text =
+ diff.outerContextStart + diff.innerContextStart + diff.html + diff.innerContextEnd + diff.outerContextEnd;
+ text = this.lineNumberingService.insertLineNumbers(text, lineLength, null, null, firstLine);
+ return text;
+ }
+
+ /**
+ * This is a workardoun to prevent the last word of the inserted text from accidently being merged with the
+ * first word of the following line.
+ *
+ * This happens as trailing spaces in the change recommendation's text are frequently stripped,
+ * which is pretty nasty if the original text goes on after the affected line. So we insert a space
+ * if the original line ends with one.
+ *
+ * @param {Element|DocumentFragment} element
+ */
+ private insertDanglingSpace(element: Element | DocumentFragment): void {
+ if (element.childNodes.length > 0) {
+ let lastChild = element.childNodes[element.childNodes.length - 1];
+ if (
+ lastChild.nodeType === TEXT_NODE &&
+ !lastChild.nodeValue.match(/[\S]/) &&
+ element.childNodes.length > 1
+ ) {
+ // If the text node only contains whitespaces, chances are high it's just space between block elmeents,
+ // like a line break between
and
+ lastChild = element.childNodes[element.childNodes.length - 2];
+ }
+ if (lastChild.nodeType === TEXT_NODE) {
+ if (lastChild.nodeValue === '' || lastChild.nodeValue.substr(-1) !== ' ') {
+ lastChild.nodeValue += ' ';
+ }
+ } else {
+ this.insertDanglingSpace(lastChild);
+ }
+ }
+ }
+
+ /**
+ * This functions merges to arrays of nodes. The last element of nodes1 and the first element of nodes2
+ * are merged, if they are of the same type.
+ *
+ * This is done recursively until a TEMPLATE-Tag is is found, which was inserted in this.replaceLines.
+ * Using a TEMPLATE-Tag is a rather dirty hack, as it is allowed inside of any other element, including
.
+ *
+ * @param {Node[]} nodes1
+ * @param {Node[]} nodes2
+ * @returns {Node[]}
+ */
+ public replaceLinesMergeNodeArrays(nodes1: Node[], nodes2: Node[]): Node[] {
+ if (nodes1.length === 0) {
+ return nodes2;
+ }
+ if (nodes2.length === 0) {
+ return nodes1;
+ }
+
+ const out = [];
+ for (let i = 0; i < nodes1.length - 1; i++) {
+ out.push(nodes1[i]);
+ }
+
+ const lastNode = nodes1[nodes1.length - 1],
+ firstNode = nodes2[0];
+ if (lastNode.nodeType === TEXT_NODE && firstNode.nodeType === TEXT_NODE) {
+ const newTextNode = lastNode.ownerDocument.createTextNode(lastNode.nodeValue + firstNode.nodeValue);
+ out.push(newTextNode);
+ } else if (lastNode.nodeName === firstNode.nodeName) {
+ const lastElement = lastNode,
+ newNode = lastNode.ownerDocument.createElement(lastNode.nodeName);
+ for (let i = 0; i < lastElement.attributes.length; i++) {
+ const attr = lastElement.attributes[i];
+ newNode.setAttribute(attr.name, attr.value);
+ }
+
+ // Remove #text nodes inside of List elements (OL/UL), as they are confusing
+ let lastChildren, firstChildren;
+ if (lastElement.nodeName === 'OL' || lastElement.nodeName === 'UL') {
+ lastChildren = [];
+ firstChildren = [];
+ for (let i = 0; i < firstNode.childNodes.length; i++) {
+ if (firstNode.childNodes[i].nodeType === ELEMENT_NODE) {
+ firstChildren.push(firstNode.childNodes[i]);
+ }
+ }
+ for (let i = 0; i < lastElement.childNodes.length; i++) {
+ if (lastElement.childNodes[i].nodeType === ELEMENT_NODE) {
+ lastChildren.push(lastElement.childNodes[i]);
+ }
+ }
+ } else {
+ lastChildren = lastElement.childNodes;
+ firstChildren = firstNode.childNodes;
+ }
+
+ const children = this.replaceLinesMergeNodeArrays(lastChildren, firstChildren);
+ for (let i = 0; i < children.length; i++) {
+ newNode.appendChild(children[i]);
+ }
+
+ out.push(newNode);
+ } else {
+ if (lastNode.nodeName !== 'TEMPLATE') {
+ out.push(lastNode);
+ }
+ if (firstNode.nodeName !== 'TEMPLATE') {
+ out.push(firstNode);
+ }
+ }
+
+ for (let i = 1; i < nodes2.length; i++) {
+ out.push(nodes2[i]);
+ }
+
+ return out;
+ }
+
+ /**
+ * This returns the line number range in which changes (insertions, deletions) are encountered.
+ * As in extractRangeByLineNumbers(), "to" refers to the line breaking element at the end, i.e. the start of the following line.
+ *
+ * @param {string} diffHtml
+ * @returns {LineRange}
+ */
+ public detectAffectedLineRange(diffHtml: string): LineRange {
+ const cacheKey = this.lineNumberingService.djb2hash(diffHtml),
+ cached = this.diffCache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ const fragment = this.htmlToFragment(diffHtml);
+
+ this.insertInternalLineMarkers(fragment);
+
+ const changes = fragment.querySelectorAll('ins, del, .insert, .delete'),
+ firstChange = changes.item(0),
+ lastChange = changes.item(changes.length - 1);
+
+ if (!firstChange || !lastChange) {
+ // There are no changes
+ return null;
+ }
+
+ const firstTrace = this.getNodeContextTrace(firstChange);
+ let lastLineNumberBefore = null;
+ for (let j = firstTrace.length - 1; j >= 0 && lastLineNumberBefore === null; j--) {
+ const prevSiblings = this.getAllPrevSiblingsReversed(firstTrace[j]);
+ for (let i = 0; i < prevSiblings.length && lastLineNumberBefore === null; i++) {
+ lastLineNumberBefore = this.getLastLineNumberNode(prevSiblings[i]);
+ }
+ }
+
+ const lastTrace = this.getNodeContextTrace(lastChange);
+ let firstLineNumberAfter = null;
+ for (let j = lastTrace.length - 1; j >= 0 && firstLineNumberAfter === null; j--) {
+ const nextSiblings = this.getAllNextSiblings(lastTrace[j]);
+ for (let i = 0; i < nextSiblings.length && firstLineNumberAfter === null; i++) {
+ firstLineNumberAfter = this.getFirstLineNumberNode(nextSiblings[i]);
+ }
+ }
+
+ const range = {
+ from: parseInt(lastLineNumberBefore.getAttribute('data-line-number'), 10),
+ to: parseInt(firstLineNumberAfter.getAttribute('data-line-number'), 10)
+ };
+
+ this.diffCache.put(cacheKey, range);
+ return range;
+ }
+
+ /**
+ * Removes .delete-nodes and -Tags (including content)
+ * Removes the .insert-classes and the wrapping -Tags (while maintaining content)
+ *
+ * @param {string} html
+ * @returns {string}
+ */
+ public diffHtmlToFinalText(html: string): string {
+ const fragment = this.htmlToFragment(html);
+
+ const delNodes = fragment.querySelectorAll('.delete, del');
+ for (let i = 0; i < delNodes.length; i++) {
+ delNodes[i].parentNode.removeChild(delNodes[i]);
+ }
+
+ const insNodes = fragment.querySelectorAll('ins');
+ for (let i = 0; i < insNodes.length; i++) {
+ const ins = insNodes[i];
+ while (ins.childNodes.length > 0) {
+ const child = ins.childNodes.item(0);
+ ins.removeChild(child);
+ ins.parentNode.insertBefore(child, ins);
+ }
+ ins.parentNode.removeChild(ins);
+ }
+
+ const insertNodes = fragment.querySelectorAll('.insert');
+ for (let i = 0; i < insertNodes.length; i++) {
+ this.removeCSSClass(insertNodes[i], 'insert');
+ }
+
+ return this.serializeDom(fragment, false);
+ }
+
+ /**
+ * Given a line numbered string (`oldHtml`), this method removes the text between `fromLine` and `toLine`
+ * and replaces it by the string given by `newHTML`.
+ * While replacing it, it also merges HTML tags that have been split to create the `newHTML` fragment,
+ * indicated by the CSS classes .os-split-before and .os-split-after.
+ *
+ * This is used for creating the consolidated version of motions.
+ *
+ * @param {string} oldHtml
+ * @param {string} newHTML
+ * @param {number} fromLine
+ * @param {number} toLine
+ */
+ public replaceLines(oldHtml: string, newHTML: string, fromLine: number, toLine: number): string {
+ const data = this.extractRangeByLineNumbers(oldHtml, fromLine, toLine),
+ previousHtml = data.previousHtml + '' + data.previousHtmlEndSnippet,
+ previousFragment = this.htmlToFragment(previousHtml),
+ followingHtml = data.followingHtmlStartSnippet + '' + data.followingHtml,
+ followingFragment = this.htmlToFragment(followingHtml),
+ newFragment = this.htmlToFragment(newHTML);
+
+ if (data.html.length > 0 && data.html.substr(-1) === ' ') {
+ this.insertDanglingSpace(newFragment);
+ }
+
+ let merged = this.replaceLinesMergeNodeArrays(
+ Array.prototype.slice.call(previousFragment.childNodes),
+ Array.prototype.slice.call(newFragment.childNodes)
+ );
+ merged = this.replaceLinesMergeNodeArrays(merged, Array.prototype.slice.call(followingFragment.childNodes));
+
+ const mergedFragment = document.createDocumentFragment();
+ for (let i = 0; i < merged.length; i++) {
+ mergedFragment.appendChild(merged[i]);
+ }
+
+ const forgottenTemplates = mergedFragment.querySelectorAll('TEMPLATE');
+ for (let i = 0; i < forgottenTemplates.length; i++) {
+ const el = forgottenTemplates[i];
+ el.parentNode.removeChild(el);
+ }
+
+ const forgottenSplitClasses = mergedFragment.querySelectorAll('.os-split-before, .os-split-after');
+ for (let i = 0; i < forgottenSplitClasses.length; i++) {
+ this.removeCSSClass(forgottenSplitClasses[i], 'os-split-before');
+ this.removeCSSClass(forgottenSplitClasses[i], 'os-split-after');
+ }
+
+ return this.serializeDom(mergedFragment, true);
+ }
+
+ /**
+ * If the inline diff does not work, we fall back to showing the diff on a paragraph base, i.e. deleting the old
+ * paragraph (adding the "deleted"-class) and adding the new one (adding the "added" class).
+ * If the provided Text is not wrapped in HTML elements but inline text, the returned text is using
+ * /-tags instead of adding CSS-classes to the wrapping element.
+ *
+ * @param {string} oldText
+ * @param {string} newText
+ * @param {number|null} lineLength
+ * @param {number|null} firstLineNumber
+ * @returns {string}
+ */
+ private diffParagraphs(oldText: string, newText: string, lineLength: number, firstLineNumber: number): string {
+ let oldTextWithBreaks, newTextWithBreaks, currChild;
+
+ if (lineLength !== null) {
+ oldTextWithBreaks = this.lineNumberingService.insertLineNumbersNode(
+ oldText,
+ lineLength,
+ null,
+ firstLineNumber
+ );
+ newTextWithBreaks = this.lineNumberingService.insertLineNumbersNode(
+ newText,
+ lineLength,
+ null,
+ firstLineNumber
+ );
+ } else {
+ oldTextWithBreaks = document.createElement('div');
+ oldTextWithBreaks.innerHTML = oldText;
+ newTextWithBreaks = document.createElement('div');
+ newTextWithBreaks.innerHTML = newText;
+ }
+
+ for (let i = 0; i < oldTextWithBreaks.childNodes.length; i++) {
+ currChild = oldTextWithBreaks.childNodes[i];
+ if (currChild.nodeType === TEXT_NODE) {
+ const wrapDel = document.createElement('del');
+ oldTextWithBreaks.insertBefore(wrapDel, currChild);
+ oldTextWithBreaks.removeChild(currChild);
+ wrapDel.appendChild(currChild);
+ } else {
+ this.addCSSClass(currChild, 'delete');
+ this.removeColorStyles(currChild);
+ }
+ }
+ for (let i = 0; i < newTextWithBreaks.childNodes.length; i++) {
+ currChild = newTextWithBreaks.childNodes[i];
+ if (currChild.nodeType === TEXT_NODE) {
+ const wrapIns = document.createElement('ins');
+ newTextWithBreaks.insertBefore(wrapIns, currChild);
+ newTextWithBreaks.removeChild(currChild);
+ wrapIns.appendChild(currChild);
+ } else {
+ this.addCSSClass(currChild, 'insert');
+ this.removeColorStyles(currChild);
+ }
+ }
+
+ const mergedFragment = document.createDocumentFragment();
+ let el;
+ while (oldTextWithBreaks.firstChild) {
+ el = oldTextWithBreaks.firstChild;
+ oldTextWithBreaks.removeChild(el);
+ mergedFragment.appendChild(el);
+ }
+ while (newTextWithBreaks.firstChild) {
+ el = newTextWithBreaks.firstChild;
+ newTextWithBreaks.removeChild(el);
+ mergedFragment.appendChild(el);
+ }
+
+ return this.serializeDom(mergedFragment, false);
+ }
+
+ /**
+ * This function calculates the diff between two strings and tries to fix problems with the resulting HTML.
+ * If lineLength and firstLineNumber is given, line numbers will be returned es well
+ *
+ * @param {string} htmlOld
+ * @param {string} htmlNew
+ * @param {number} lineLength - optional
+ * @param {number} firstLineNumber - optional
+ * @returns {string}
+ */
+ public diff(htmlOld: string, htmlNew: string, lineLength: number = null, firstLineNumber: number = null): string {
+ const cacheKey =
+ lineLength +
+ ' ' +
+ firstLineNumber +
+ ' ' +
+ this.lineNumberingService.djb2hash(htmlOld) +
+ this.lineNumberingService.djb2hash(htmlNew),
+ cached = this.diffCache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ // This fixes a really strange artefact with the diff that occures under the following conditions:
+ // - The first tag of the two texts is identical, e.g.
+ // - A change happens in the next tag, e.g. inserted text
+ // - The first tag occures a second time in the text, e.g. another
+ // In this condition, the first tag is deleted first and inserted afterwards again
+ // Test case: "does not break when an insertion followes a beginning tag occuring twice"
+ // The work around inserts to tags at the beginning and removes them afterwards again,
+ // to make sure this situation does not happen (and uses invisible pseudo-tags in case something goes wrong)
+ const workaroundPrepend = '';
+
+ // os-split-after should not be considered for detecting changes in paragraphs, so we strip it here
+ // and add it afterwards.
+ // We only do this for P for now, as for more complex types like UL/LI that tend to be nestend,
+ // information would get lost by this that we will need to recursively merge it again later on.
+ let oldIsSplitAfter = false,
+ newIsSplitAfter = false;
+ htmlOld = htmlOld.replace(
+ /(\s*
leo Testelefantgeweih Buchstabenwut als Achzehnzahlunginer. Hierbei darfsetzen bist der Deifi das Dor Reh Wachtel da Subjunktivier als Derftige Aalsan Orthopädische, der Arbeitsnachweisdiskus Bass der Tastatur Weiter schreiben wie Tasse Wasser als dienen.
' +
+ noMarkup(1) +
+ 'leo Testelefantgeweih Buchstabenwut als Achzehnzahlunginer. Hierbei darfsetzen bist der Deifi das ' +
+ brMarkup(2) +
+ 'Dor Reh Wachtel da Subjunktivier als Derftige Aalsan Orthopädische, der Arbeitsnachweisdiskus Bass der Tastatur ' +
+ brMarkup(3) +
+ 'Weiter schreiben wie Tasse Wasser als dienen.
';
+ const outHtml = service.insertLineNumbers(inHtml, 80);
+ expect(outHtml).toBe(expected);
+ expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
+ expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
+ }
+ ));
+
+ it('breaks before an inline element, if the first word of the new inline element is longer than the remaining line (1)', inject(
+ [LinenumberingService],
+ (service: LinenumberingService) => {
+ const inHtml =
+ '
Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio.
' +
+ noMarkup(1) +
+ 'Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie ' +
+ brMarkup(2) +
+ 'consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan ' +
+ brMarkup(3) +
+ 'et iusto odio.
'
+ );
+ expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
+ expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
+ }
+ ));
+
+ it('breaks before an inline element, if the first word of the new inline element is longer than the remaining line (2)', inject(
+ [LinenumberingService],
+ (service: LinenumberingService) => {
+ const inHtml =
+ '
Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio.
' +
+ noMarkup(1) +
+ 'Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie ' +
+ brMarkup(2) +
+ 'consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan ' +
+ brMarkup(3) +
+ 'et iusto odio.
'
+ );
+ expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
+ expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
+ }
+ ));
+
+ it('does not fail in a weird case', inject([LinenumberingService], (service: LinenumberingService) => {
+ const inHtml = 'seid Noch
'
+ );
+ expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
+ expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
+ }));
+ });
+
+ describe('line numbering in regard to the inline diff', () => {
+ it('does not count within INS nodes', inject([LinenumberingService], (service: LinenumberingService) => {
+ const inHtml = '1234 1234 1234 1234';
+ const outHtml = service.insertLineNumbers(inHtml, 10);
+ expect(outHtml).toBe(noMarkup(1) + '1234 1234 1234 ' + brMarkup(2) + '1234');
+ expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
+ expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
+ }));
+
+ it('does not create a new line for a trailing INS', inject(
+ [LinenumberingService],
+ (service: LinenumberingService) => {
+ const inHtml =
+ '
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, conseteturdsfsdf23
' +
+ noMarkup(1) +
+ 'et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata ' +
+ brMarkup(2) +
+ 'sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, conseteturdsfsdf23
'
+ );
+ expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
+ expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
+ }
+ ));
+
+ it('inserts the line number before the INS, if INS is the first element of the paragraph', inject(
+ [LinenumberingService],
+ (service: LinenumberingService) => {
+ const inHtml =
+ "
lauthals 'liebe Kinder, ich will hinaus in den Wald, seid auf der Hut vor dem Wolf!' Und noch etwas mehr Text bis zur nächsten Zeile
' +
+ noMarkup(1) +
+ "lauthals 'liebe Kinder, ich will hinaus in den Wald, seid auf der Hut vor dem Wolf!' Und " +
+ brMarkup(2) +
+ 'noch etwas mehr Text bis zur nächsten Zeile
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, conseteturdsfsdf23
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata ' +
+ plainBr +
+ 'sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur' +
+ plainBr +
+ 'dsfsdf23
';
+ const outHtml80 = service.insertLineNumbers(inHtml, 80);
+ const outHtml70 = service.insertLineNumbers(inHtml, 70);
+ expect(outHtml70).not.toBe(outHtml80);
+ }));
+ });
+});
diff --git a/client/src/app/site/motions/services/linenumbering.service.ts b/client/src/app/site/motions/services/linenumbering.service.ts
new file mode 100644
index 000000000..d5af49ade
--- /dev/null
+++ b/client/src/app/site/motions/services/linenumbering.service.ts
@@ -0,0 +1,1028 @@
+import { Injectable } from '@angular/core';
+
+const ELEMENT_NODE = 1;
+const TEXT_NODE = 3;
+
+/**
+ * Specifies a point within a HTML Text Node where a line break might be possible, if the following word
+ * exceeds the maximum line length.
+ */
+interface BreakablePoint {
+ /**
+ * The Text node which is a candidate to be split into two.
+ */
+ node: Node;
+ /**
+ * The exact offset of the found breakable point.
+ */
+ offset: number;
+}
+
+/**
+ * An object specifying a range of line numbers.
+ */
+interface LineNumberRange {
+ /**
+ * The first line number to be included.
+ */
+ from: number;
+ /**
+ * The end line number.
+ * HINT: As this object is usually referring to actual line numbers, not lines,
+ * the line starting by `to` is not included in the extracted content anymore, only the text between `from` and `to`.
+ */
+ to: number;
+}
+
+/**
+ * Specifies a heading element (H1, H2, H3, H4, H5, H6) within a HTML document.
+ */
+interface SectionHeading {
+ /**
+ * The first line number of this element.
+ */
+ lineNumber: number;
+ /**
+ * The nesting level. H1 = 1, H2 = 2, etc.
+ */
+ level: number;
+ /**
+ * The text content of this heading.
+ */
+ text: string;
+}
+
+/**
+ * Functionality regarding adding and removing line numbers and highlighting single lines.
+ *
+ * ## Examples:
+ *
+ * Adding line numbers to an HTML string:
+ *
+ * ```ts
+ * const lineLength = 80;
+ * const originalHtml = '
';
+ * const originalHtml = this.lineNumbering.stripLineNumbers(inHtml);
+ * ```
+ *
+ * Splitting a HTML string into an array of paragraphs:
+ *
+ * ```ts
+ * const htmlIn = '
Paragraph 1
Paragraph 2
Paragraph 3
';
+ * const paragraphs = this.lineNumbering.splitToParagraphs(htmlIn);
+ * ```
+ *
+ * Retrieving all section headings from an HTML string:
+ *
+ * ```ts
+ * const html = '
Heading 1
Some introductional paragraph
Subheading 1.1
Another paragraph
+ * const headings = this.lineNumbering.getHeadingsWithLineNumbers(html);
+ * ```
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class LinenumberingService {
+ /**
+ * @TODO Decide on a more sophisticated implementation
+ * This is just a stub for a caching system. The original code from Angular1 was:
+ * var lineNumberCache = $cacheFactory('linenumbering.service');
+ * This should be replaced by a real cache once we have decided on a caching service for OpenSlides 3
+ */
+ private lineNumberCache = {
+ _cache: {},
+ get: (key: string): any => {
+ return this.lineNumberCache._cache[key] === undefined ? null : this.lineNumberCache._cache[key];
+ },
+ put: (key: string, val: any): void => {
+ this.lineNumberCache._cache[key] = val;
+ }
+ };
+
+ // Counts the number of characters in the current line, beyond singe nodes.
+ // Needs to be resetted after each line break and after entering a new block node.
+ private currentInlineOffset: number = null;
+
+ // The last position of a point suitable for breaking the line. null or an object with the following values:
+ // - node: the node that contains the position. Guaranteed to be a TextNode
+ // - offset: the offset of the breaking characters (like the space)
+ // Needs to be resetted after each line break and after entering a new block node.
+ private lastInlineBreakablePoint: BreakablePoint = null;
+
+ // The line number counter
+ private currentLineNumber: number = null;
+
+ // Indicates that we just entered a block element and we want to add a line number without line break at the beginning.
+ private prependLineNumberToFirstText = false;
+
+ // A workaround to prevent double line numbers
+ private ignoreNextRegularLineNumber = false;
+
+ // Decides if the content of inserted nodes should count as well. This is used so we can use the algorithm on a
+ // text with inline diff annotations and get the same line numbering as with the original text (when set to false)
+ private ignoreInsertedText = false;
+
+ /**
+ * Creates a hash of a given string. This is not meant to be specifically secure, but rather as quick as possible.
+ *
+ * @param {string} str
+ * @returns {string}
+ */
+ public djb2hash(str: string): string {
+ let hash = 5381;
+ let char;
+ for (let i = 0; i < str.length; i++) {
+ char = str.charCodeAt(i);
+ // tslint:disable-next-line:no-bitwise
+ hash = (hash << 5) + hash + char;
+ }
+ return hash.toString();
+ }
+
+ /**
+ * Returns true, if the provided element is an inline element (hard-coded list of known elements).
+ *
+ * @param {Element} element
+ * @returns {boolean}
+ */
+ private isInlineElement(element: Element): boolean {
+ const inlineElements = [
+ 'SPAN',
+ 'A',
+ 'EM',
+ 'S',
+ 'B',
+ 'I',
+ 'STRONG',
+ 'U',
+ 'BIG',
+ 'SMALL',
+ 'SUB',
+ 'SUP',
+ 'TT',
+ 'INS',
+ 'DEL',
+ 'STRIKE'
+ ];
+ return inlineElements.indexOf(element.nodeName) > -1;
+ }
+
+ /**
+ * Returns true, if the given node is a OpenSlides-specific line breaking node.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+ public isOsLineBreakNode(node: Node): boolean {
+ let isLineBreak = false;
+ if (node && node.nodeType === ELEMENT_NODE) {
+ const element = node;
+ if (element.nodeName === 'BR' && element.hasAttribute('class')) {
+ const classes = element.getAttribute('class').split(' ');
+ if (classes.indexOf('os-line-break') > -1) {
+ isLineBreak = true;
+ }
+ }
+ }
+ return isLineBreak;
+ }
+
+ /**
+ * Returns true, if the given node is a OpenSlides-specific line numbering node.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+ public isOsLineNumberNode(node: Node): boolean {
+ let isLineNumber = false;
+ if (node && node.nodeType === ELEMENT_NODE) {
+ const element = node;
+ if (node.nodeName === 'SPAN' && element.hasAttribute('class')) {
+ const classes = element.getAttribute('class').split(' ');
+ if (classes.indexOf('os-line-number') > -1) {
+ isLineNumber = true;
+ }
+ }
+ }
+ return isLineNumber;
+ }
+
+ /**
+ * Searches for the line breaking node within the given Document specified by the given lineNumber.
+ *
+ * @param {DocumentFragment} fragment
+ * @param {number} lineNumber
+ * @returns {Element}
+ */
+ private getLineNumberNode(fragment: DocumentFragment, lineNumber: number): Element {
+ return fragment.querySelector('.os-line-number.line-number-' + lineNumber);
+ }
+
+ /**
+ * This converts the given HTML string into a DOM tree contained by a DocumentFragment, which is reqturned.
+ *
+ * @param {string} html
+ * @return {DocumentFragment}
+ */
+ private htmlToFragment(html: string): DocumentFragment {
+ const fragment: DocumentFragment = document.createDocumentFragment(),
+ div = document.createElement('DIV');
+ div.innerHTML = html;
+ while (div.childElementCount) {
+ const child = div.childNodes[0];
+ div.removeChild(child);
+ fragment.appendChild(child);
+ }
+ return fragment;
+ }
+
+ /**
+ * Converts a HTML Document Fragment into HTML string, using the browser's internal mechanisms.
+ * HINT: special characters might get escaped / html-encoded in the process of this.
+ *
+ * @param {DocumentFragment} fragment
+ * @returns string
+ */
+ private fragmentToHtml(fragment: DocumentFragment): string {
+ const div: Element = document.createElement('DIV');
+ while (fragment.firstChild) {
+ const child = fragment.firstChild;
+ fragment.removeChild(child);
+ div.appendChild(child);
+ }
+ return div.innerHTML;
+ }
+
+ /**
+ * Creates a OpenSlides-specific line break Element
+ *
+ * @returns {Element}
+ */
+ private createLineBreak(): Element {
+ const br = document.createElement('br');
+ br.setAttribute('class', 'os-line-break');
+ return br;
+ }
+
+ /**
+ * Moves line breaking and line numbering markup before inline elements
+ *
+ * @param {Element} innerNode
+ * @param {Element} outerNode
+ * @private
+ */
+ private moveLeadingLineBreaksToOuterNode(innerNode: Element, outerNode: Element): void {
+ if (this.isInlineElement(innerNode)) {
+ const firstChild = innerNode.firstChild;
+ if (this.isOsLineBreakNode(firstChild)) {
+ const br = innerNode.firstChild;
+ innerNode.removeChild(br);
+ outerNode.appendChild(br);
+ }
+ if (this.isOsLineNumberNode(firstChild)) {
+ const span = innerNode.firstChild;
+ innerNode.removeChild(span);
+ outerNode.appendChild(span);
+ }
+ }
+ }
+
+ /**
+ * As some elements add extra paddings/margins, the maximum line length of the contained text is not as big
+ * as for text outside of this element. Based on the outside line length, this returns the new (reduced) maximum
+ * line length for the given block element.
+ * HINT: this makes quite some assumtions about the styling of the CSS / PDFs. But there is no way around this,
+ * as line numbers have to be fixed and not depend on styling.
+ *
+ * @param {Element} node
+ * @param {number} oldLength
+ * @returns {number}
+ */
+ public calcBlockNodeLength(node: Element, oldLength: number): number {
+ let newLength = oldLength;
+ switch (node.nodeName) {
+ case 'LI':
+ newLength -= 5;
+ break;
+ case 'BLOCKQUOTE':
+ newLength -= 20;
+ break;
+ case 'DIV':
+ case 'P':
+ const styles = node.getAttribute('style');
+ let padding = 0;
+ if (styles) {
+ const leftpad = styles.split('padding-left:');
+ if (leftpad.length > 1) {
+ padding += parseInt(leftpad[1], 10);
+ }
+ const rightpad = styles.split('padding-right:');
+ if (rightpad.length > 1) {
+ padding += parseInt(rightpad[1], 10);
+ }
+ newLength -= padding / 5;
+ }
+ break;
+ case 'H1':
+ newLength *= 0.66;
+ break;
+ case 'H2':
+ newLength *= 0.75;
+ break;
+ case 'H3':
+ newLength *= 0.85;
+ break;
+ }
+ return Math.ceil(newLength);
+ }
+
+ /**
+ * This converts an array of HTML elements into a string
+ *
+ * @param {Element[]} nodes
+ * @returns {string}
+ */
+ public nodesToHtml(nodes: Element[]): string {
+ const root = document.createElement('div');
+ nodes.forEach(node => {
+ root.appendChild(node);
+ });
+ return root.innerHTML;
+ }
+
+ /**
+ * Given a HTML string augmented with line number nodes, this function detects the line number range of this text.
+ * This method assumes that the line number node indicating the beginning of the next line is not included anymore.
+ *
+ * @param {string} html
+ * @returns {LineNumberRange}
+ */
+ public getLineNumberRange(html: string): LineNumberRange {
+ const fragment = this.htmlToFragment(html);
+ const range = {
+ from: null,
+ to: null
+ };
+ const lineNumbers = fragment.querySelectorAll('.os-line-number');
+ for (let i = 0; i < lineNumbers.length; i++) {
+ const node = lineNumbers.item(i);
+ const number = parseInt(node.getAttribute('data-line-number'), 10);
+ if (range.from === null || number < range.from) {
+ range.from = number;
+ }
+ if (range.to === null || number + 1 > range.to) {
+ range.to = number + 1;
+ }
+ }
+ return range;
+ }
+
+ /**
+ * Seaches for all H1-H6 elements within the given text and returns information about them.
+ *
+ * @param {string} html
+ * @returns {SectionHeading[]}
+ */
+ public getHeadingsWithLineNumbers(html: string): SectionHeading[] {
+ const fragment = this.htmlToFragment(html);
+ const headings = [];
+ const headingNodes = fragment.querySelectorAll('h1, h2, h3, h4, h5, h6');
+ for (let i = 0; i < headingNodes.length; i++) {
+ const heading = headingNodes.item(i);
+ const linenumbers = heading.querySelectorAll('.os-line-number');
+ if (linenumbers.length > 0) {
+ const number = parseInt(linenumbers.item(0).getAttribute('data-line-number'), 10);
+ headings.push({
+ lineNumber: number,
+ level: parseInt(heading.nodeName.substr(1), 10),
+ text: heading.innerText.replace(/^\s/, '').replace(/\s$/, '')
+ });
+ }
+ }
+ return headings.sort(
+ (heading1: SectionHeading, heading2: SectionHeading): number => {
+ if (heading1.lineNumber < heading2.lineNumber) {
+ return 0;
+ } else if (heading1.lineNumber > heading2.lineNumber) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+ );
+ }
+
+ /**
+ * Given a big element containing a whole document, this method splits it into editable paragraphs.
+ * Each first-level LI element gets its own paragraph, as well as all root-level block elements (except for lists).
+ *
+ * @param {Element|DocumentFragment} node
+ * @returns {Element[]}
+ * @private
+ */
+ public splitNodeToParagraphs(node: Element | DocumentFragment): Element[] {
+ const elements = [];
+ for (let i = 0; i < node.childNodes.length; i++) {
+ const childNode = node.childNodes.item(i);
+
+ if (childNode.nodeType === TEXT_NODE) {
+ continue;
+ }
+ if (childNode.nodeName === 'UL' || childNode.nodeName === 'OL') {
+ const childElement = childNode;
+ let start = 1;
+ if (childElement.getAttribute('start') !== null) {
+ start = parseInt(childElement.getAttribute('start'), 10);
+ }
+ for (let j = 0; j < childElement.childNodes.length; j++) {
+ if (childElement.childNodes.item(j).nodeType === TEXT_NODE) {
+ continue;
+ }
+ const newParent = childElement.cloneNode(false);
+ if (childElement.nodeName === 'OL') {
+ newParent.setAttribute('start', start.toString());
+ }
+ newParent.appendChild(childElement.childNodes.item(j).cloneNode(true));
+ elements.push(newParent);
+ start++;
+ }
+ } else {
+ elements.push(childNode);
+ }
+ }
+ return elements;
+ }
+
+ /**
+ * Splitting the text into paragraphs:
+ * - Each root-level-element is considered as a paragraph.
+ * Inline-elements at root-level are not expected and treated as block elements.
+ * Text-nodes at root-level are not expected and ignored. Every text needs to be wrapped e.g. by
or
.
+ * - If a UL or OL is encountered, paragraphs are defined by the child-LI-elements.
+ * List items of nested lists are not considered as a paragraph of their own.
+ *
+ * @param {string} html
+ * @return {string[]}
+ */
+ public splitToParagraphs(html: string): string[] {
+ const fragment = this.htmlToFragment(html);
+ return this.splitNodeToParagraphs(fragment).map(
+ (node: Element): string => {
+ return node.outerHTML;
+ }
+ );
+ }
+
+ /**
+ * Test function, only called by the tests, never from the actual app
+ *
+ * @param {number} offset
+ * @param {number} lineNumber
+ */
+ public setInlineOffsetLineNumberForTests(offset: number, lineNumber: number): void {
+ this.currentInlineOffset = offset;
+ this.currentLineNumber = lineNumber;
+ }
+
+ /**
+ * Returns debug information for the test cases
+ *
+ * @returns {number}
+ */
+ public getInlineOffsetForTests(): number {
+ return this.currentInlineOffset;
+ }
+
+ /**
+ * When calculating line numbers on a diff-marked-up text, some elements should not be considered:
+ * inserted text and line numbers. This identifies such elements.
+ *
+ * @param {Element} element
+ * @returns {boolean}
+ */
+ private isIgnoredByLineNumbering(element: Element): boolean {
+ if (element.nodeName === 'INS') {
+ return this.ignoreInsertedText;
+ } else if (this.isOsLineNumberNode(element)) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * This creates a line number node with the next free line number.
+ * If the internal flag is set, this step is skipped.
+ *
+ * @returns {Element}
+ */
+ private createLineNumber(): Element {
+ if (this.ignoreNextRegularLineNumber) {
+ this.ignoreNextRegularLineNumber = false;
+ return;
+ }
+ const node = document.createElement('span');
+ const lineNumber = this.currentLineNumber;
+ this.currentLineNumber++;
+ node.setAttribute('class', 'os-line-number line-number-' + lineNumber);
+ node.setAttribute('data-line-number', lineNumber + '');
+ node.setAttribute('contenteditable', 'false');
+ node.innerHTML = ' '; // Prevent ckeditor from stripping out empty span's
+ return node;
+ }
+
+ /**
+ * Splits a TEXT_NODE into an array of TEXT_NODEs and BR-Elements separating them into lines.
+ * Each line has a maximum length of 'length', with one exception: spaces are accepted to exceed the length.
+ * Otherwise the string is split by the last space or dash in the line.
+ *
+ * @param {Node} node
+ * @param {number} length
+ * @param {number} highlight
+ */
+ public textNodeToLines(node: Node, length: number, highlight: number = -1): Element[] {
+ const out = [];
+ let currLineStart = 0,
+ i = 0,
+ firstTextNode = true;
+ const addLine = (text: string) => {
+ let lineNode;
+ if (firstTextNode) {
+ if (highlight === this.currentLineNumber - 1) {
+ lineNode = document.createElement('span');
+ lineNode.setAttribute('class', 'highlight');
+ lineNode.innerHTML = text;
+ } else {
+ lineNode = document.createTextNode(text);
+ }
+ firstTextNode = false;
+ } else {
+ if (this.currentLineNumber === highlight && highlight !== null) {
+ lineNode = document.createElement('span');
+ lineNode.setAttribute('class', 'highlight');
+ lineNode.innerHTML = text;
+ } else {
+ lineNode = document.createTextNode(text);
+ }
+ out.push(this.createLineBreak());
+ if (this.currentLineNumber !== null) {
+ out.push(this.createLineNumber());
+ }
+ }
+ out.push(lineNode);
+ return lineNode;
+ };
+ const addLinebreakToPreviousNode = (lineNode: Element, offset: number) => {
+ const firstText = lineNode.nodeValue.substr(0, offset + 1),
+ secondText = lineNode.nodeValue.substr(offset + 1);
+ const lineBreak = this.createLineBreak();
+ const firstNode = document.createTextNode(firstText);
+ lineNode.parentNode.insertBefore(firstNode, lineNode);
+ lineNode.parentNode.insertBefore(lineBreak, lineNode);
+ if (this.currentLineNumber !== null) {
+ lineNode.parentNode.insertBefore(this.createLineNumber(), lineNode);
+ }
+ lineNode.nodeValue = secondText;
+ };
+
+ if (node.nodeValue === '\n') {
+ out.push(node);
+ } else {
+ // This happens if a previous inline element exactly stretches to the end of the line
+ if (this.currentInlineOffset >= length) {
+ out.push(this.createLineBreak());
+ if (this.currentLineNumber !== null) {
+ out.push(this.createLineNumber());
+ }
+ this.currentInlineOffset = 0;
+ this.lastInlineBreakablePoint = null;
+ } else if (this.prependLineNumberToFirstText) {
+ if (this.ignoreNextRegularLineNumber) {
+ this.ignoreNextRegularLineNumber = false;
+ } else if (this.currentLineNumber !== null) {
+ out.push(this.createLineNumber());
+ }
+ }
+ this.prependLineNumberToFirstText = false;
+
+ while (i < node.nodeValue.length) {
+ let lineBreakAt = null;
+ if (this.currentInlineOffset >= length) {
+ if (this.lastInlineBreakablePoint !== null) {
+ lineBreakAt = this.lastInlineBreakablePoint;
+ } else {
+ lineBreakAt = {
+ node: node,
+ offset: i - 1
+ };
+ }
+ }
+ if (lineBreakAt !== null && (node.nodeValue[i] !== ' ' && node.nodeValue[i] !== '\n')) {
+ if (lineBreakAt.node === node) {
+ // The last possible breaking point is in this text node
+ const currLine = node.nodeValue.substring(currLineStart, lineBreakAt.offset + 1);
+ addLine(currLine);
+
+ currLineStart = lineBreakAt.offset + 1;
+ this.currentInlineOffset = i - lineBreakAt.offset - 1;
+ this.lastInlineBreakablePoint = null;
+ } else {
+ // The last possible breaking point was not in this text not, but one we have already passed
+ const remainderOfPrev = lineBreakAt.node.nodeValue.length - lineBreakAt.offset - 1;
+ addLinebreakToPreviousNode(lineBreakAt.node, lineBreakAt.offset);
+
+ this.currentInlineOffset = i + remainderOfPrev;
+ this.lastInlineBreakablePoint = null;
+ }
+ }
+
+ if (node.nodeValue[i] === ' ' || node.nodeValue[i] === '-' || node.nodeValue[i] === '\n') {
+ this.lastInlineBreakablePoint = {
+ node: node,
+ offset: i
+ };
+ }
+
+ this.currentInlineOffset++;
+ i++;
+ }
+ const lastLine = addLine(node.nodeValue.substring(currLineStart));
+ if (this.lastInlineBreakablePoint !== null) {
+ this.lastInlineBreakablePoint.node = lastLine;
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Searches recursively for the first textual word in a node and returns its length. Handy for detecting if
+ * the next nested element will break the current line.
+ *
+ * @param {Node} node
+ * @returns {number}
+ */
+ private lengthOfFirstInlineWord(node: Node): number {
+ if (!node.firstChild) {
+ return 0;
+ }
+ if (node.firstChild.nodeType === TEXT_NODE) {
+ const parts = node.firstChild.nodeValue.split(' ');
+ return parts[0].length;
+ } else {
+ return this.lengthOfFirstInlineWord(node.firstChild);
+ }
+ }
+
+ /**
+ * Given an inline node, this method adds line numbers to it based on the current state.
+ *
+ * @param {Element} element
+ * @param {number} length
+ * @param {number} highlight
+ */
+ private insertLineNumbersToInlineNode(element: Element, length: number, highlight?: number): Element {
+ const oldChildren: Node[] = [];
+ for (let i = 0; i < element.childNodes.length; i++) {
+ oldChildren.push(element.childNodes[i]);
+ }
+
+ while (element.firstChild) {
+ element.removeChild(element.firstChild);
+ }
+
+ for (let i = 0; i < oldChildren.length; i++) {
+ if (oldChildren[i].nodeType === TEXT_NODE) {
+ const ret = this.textNodeToLines(oldChildren[i], length, highlight);
+ for (let j = 0; j < ret.length; j++) {
+ element.appendChild(ret[j]);
+ }
+ } else if (oldChildren[i].nodeType === ELEMENT_NODE) {
+ const childElement = oldChildren[i],
+ firstword = this.lengthOfFirstInlineWord(childElement),
+ overlength = this.currentInlineOffset + firstword > length && this.currentInlineOffset > 0;
+ if (overlength && this.isInlineElement(childElement)) {
+ this.currentInlineOffset = 0;
+ this.lastInlineBreakablePoint = null;
+ element.appendChild(this.createLineBreak());
+ if (this.currentLineNumber !== null) {
+ element.appendChild(this.createLineNumber());
+ }
+ }
+ const changedNode = this.insertLineNumbersToNode(childElement, length, highlight);
+ this.moveLeadingLineBreaksToOuterNode(changedNode, element);
+ element.appendChild(changedNode);
+ } else {
+ throw new Error('Unknown nodeType: ' + i + ': ' + oldChildren[i]);
+ }
+ }
+
+ return element;
+ }
+
+ /**
+ * Given a block node, this method adds line numbers to it based on the current state.
+ *
+ * @param {Element} element
+ * @param {number} length
+ * @param {number} highlight
+ */
+ public insertLineNumbersToBlockNode(element: Element, length: number, highlight?: number): Element {
+ this.currentInlineOffset = 0;
+ this.lastInlineBreakablePoint = null;
+ this.prependLineNumberToFirstText = true;
+
+ const oldChildren = [];
+ for (let i = 0; i < element.childNodes.length; i++) {
+ oldChildren.push(element.childNodes[i]);
+ }
+
+ while (element.firstChild) {
+ element.removeChild(element.firstChild);
+ }
+
+ for (let i = 0; i < oldChildren.length; i++) {
+ if (oldChildren[i].nodeType === TEXT_NODE) {
+ if (!oldChildren[i].nodeValue.match(/\S/)) {
+ // White space nodes between block elements should be ignored
+ const prevIsBlock = i > 0 && !this.isInlineElement(oldChildren[i - 1]);
+ const nextIsBlock = i < oldChildren.length - 1 && !this.isInlineElement(oldChildren[i + 1]);
+ if (
+ (prevIsBlock && nextIsBlock) ||
+ (i === 0 && nextIsBlock) ||
+ (i === oldChildren.length - 1 && prevIsBlock)
+ ) {
+ element.appendChild(oldChildren[i]);
+ continue;
+ }
+ }
+ const ret = this.textNodeToLines(oldChildren[i], length, highlight);
+ for (let j = 0; j < ret.length; j++) {
+ element.appendChild(ret[j]);
+ }
+ } else if (oldChildren[i].nodeType === ELEMENT_NODE) {
+ const firstword = this.lengthOfFirstInlineWord(oldChildren[i]),
+ overlength = this.currentInlineOffset + firstword > length && this.currentInlineOffset > 0;
+ if (
+ overlength &&
+ this.isInlineElement(oldChildren[i]) &&
+ !this.isIgnoredByLineNumbering(oldChildren[i])
+ ) {
+ this.currentInlineOffset = 0;
+ this.lastInlineBreakablePoint = null;
+ element.appendChild(this.createLineBreak());
+ if (this.currentLineNumber !== null) {
+ element.appendChild(this.createLineNumber());
+ }
+ }
+ const changedNode = this.insertLineNumbersToNode(oldChildren[i], length, highlight);
+ this.moveLeadingLineBreaksToOuterNode(changedNode, element);
+ element.appendChild(changedNode);
+ } else {
+ throw new Error('Unknown nodeType: ' + i + ': ' + oldChildren[i]);
+ }
+ }
+
+ this.currentInlineOffset = 0;
+ this.lastInlineBreakablePoint = null;
+ this.prependLineNumberToFirstText = true;
+ this.ignoreNextRegularLineNumber = false;
+
+ return element;
+ }
+
+ /**
+ * Given any node, this method adds line numbers to it based on the current state.
+ *
+ * @param {Element} element
+ * @param {number} length
+ * @param {number} highlight
+ */
+ public insertLineNumbersToNode(element: Element, length: number, highlight?: number): Element {
+ if (element.nodeType !== ELEMENT_NODE) {
+ throw new Error('This method may only be called for ELEMENT-nodes: ' + element.nodeValue);
+ }
+ if (this.isIgnoredByLineNumbering(element)) {
+ if (this.currentInlineOffset === 0 && this.currentLineNumber !== null) {
+ const lineNumberNode = this.createLineNumber();
+ if (lineNumberNode) {
+ element.insertBefore(lineNumberNode, element.firstChild);
+ this.ignoreNextRegularLineNumber = true;
+ }
+ }
+ return element;
+ } else if (this.isInlineElement(element)) {
+ return this.insertLineNumbersToInlineNode(element, length, highlight);
+ } else {
+ const newLength = this.calcBlockNodeLength(element, length);
+ return this.insertLineNumbersToBlockNode(element, newLength, highlight);
+ }
+ }
+
+ /**
+ * Removes all line number nodes from the given Node.
+ *
+ * @param {Node} node
+ */
+ public stripLineNumbersFromNode(node: Node): void {
+ for (let i = 0; i < node.childNodes.length; i++) {
+ if (this.isOsLineBreakNode(node.childNodes[i]) || this.isOsLineNumberNode(node.childNodes[i])) {
+ // If a newline character follows a line break, it's been very likely inserted by the WYSIWYG-editor
+ if (node.childNodes.length > i + 1 && node.childNodes[i + 1].nodeType === TEXT_NODE) {
+ if (node.childNodes[i + 1].nodeValue[0] === '\n') {
+ node.childNodes[i + 1].nodeValue = ' ' + node.childNodes[i + 1].nodeValue.substring(1);
+ }
+ }
+ node.removeChild(node.childNodes[i]);
+ i--;
+ } else {
+ this.stripLineNumbersFromNode(node.childNodes[i]);
+ }
+ }
+ }
+
+ /**
+ * Adds line number nodes to the given Node.
+ *
+ * @param {string} html
+ * @param {number|string} lineLength
+ * @param {number|null} highlight - optional
+ * @param {number|null} firstLine
+ */
+ public insertLineNumbersNode(html: string, lineLength: number, highlight: number, firstLine: number = 1): Element {
+ // Removing newlines after BRs, as they lead to problems like #3410
+ if (html) {
+ html = html.replace(/( ]*>)[\n\r]+/gi, '$1');
+ }
+
+ const root = document.createElement('div');
+ root.innerHTML = html;
+
+ this.currentInlineOffset = 0;
+ this.lastInlineBreakablePoint = null;
+ this.currentLineNumber = firstLine;
+ this.prependLineNumberToFirstText = true;
+ this.ignoreNextRegularLineNumber = false;
+ this.ignoreInsertedText = true;
+
+ return this.insertLineNumbersToNode(root, lineLength, highlight);
+ }
+
+ /**
+ * Adds line number nodes to the given html string.
+ * @param {string} html
+ * @param {number} lineLength
+ * @param {number} highlight
+ * @param {function} callback
+ * @param {number} firstLine
+ * @returns {string}
+ */
+ public insertLineNumbers(
+ html: string,
+ lineLength: number,
+ highlight?: number,
+ callback?: () => void,
+ firstLine?: number
+ ): string {
+ let newHtml, newRoot;
+
+ if (highlight > 0) {
+ // Caching versions with highlighted line numbers is probably not worth it
+ newRoot = this.insertLineNumbersNode(html, lineLength, highlight, firstLine);
+ newHtml = newRoot.innerHTML;
+ } else {
+ const firstLineStr = firstLine === undefined ? '' : firstLine.toString();
+ const cacheKey = this.djb2hash(firstLineStr + '-' + lineLength.toString() + html);
+ newHtml = this.lineNumberCache.get(cacheKey);
+
+ if (!newHtml) {
+ newRoot = this.insertLineNumbersNode(html, lineLength, null, firstLine);
+ newHtml = newRoot.innerHTML;
+ this.lineNumberCache.put(cacheKey, newHtml);
+ }
+ }
+
+ if (callback !== undefined && callback !== null) {
+ callback();
+ }
+
+ return newHtml;
+ }
+
+ /**
+ * This enforces that no line is longer than the given line Length. However, this method does not care about
+ * line numbers, diff-markup etc.
+ * It's mainly used to display diff-formatted text with the original line numbering, that may have longer lines
+ * than the line length because of inserted text, in a context where really only a given width is available.
+ *
+ * @param {string} html
+ * @param {number} lineLength
+ * @param {boolean} countInserted
+ * @returns {string}
+ */
+ public insertLineBreaksWithoutNumbers(html: string, lineLength: number, countInserted: boolean = false): string {
+ const root = document.createElement('div');
+ root.innerHTML = html;
+
+ this.currentInlineOffset = 0;
+ this.lastInlineBreakablePoint = null;
+ this.currentLineNumber = null;
+ this.prependLineNumberToFirstText = true;
+ this.ignoreNextRegularLineNumber = false;
+ this.ignoreInsertedText = !countInserted;
+
+ const newRoot = this.insertLineNumbersToNode(root, lineLength, null);
+
+ return newRoot.innerHTML;
+ }
+
+ /**
+ * Strips line numbers from a HTML string
+ *
+ * @param {string} html
+ * @returns {string}
+ */
+ public stripLineNumbers(html: string): string {
+ const root = document.createElement('div');
+ root.innerHTML = html;
+ this.stripLineNumbersFromNode(root);
+ return root.innerHTML;
+ }
+
+ /**
+ * Traverses up the DOM tree until it finds a node with a nextSibling, then returns that sibling
+ *
+ * @param {Node} node
+ * @returns {Node}
+ */
+ public findNextAuntNode(node: Node): Node {
+ if (node.nextSibling) {
+ return node.nextSibling;
+ } else if (node.parentNode) {
+ return this.findNextAuntNode(node.parentNode);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Highlights (span[class=highlight]) all text starting from the given line number Node to the next one found.
+ *
+ * @param {Element} lineNumberNode
+ */
+ public highlightUntilNextLine(lineNumberNode: Element): void {
+ let currentNode: Node = lineNumberNode,
+ foundNextLineNumber = false;
+
+ do {
+ let wasHighlighted = false;
+ if (currentNode.nodeType === TEXT_NODE) {
+ const node = document.createElement('span');
+ node.setAttribute('class', 'highlight');
+ node.innerHTML = currentNode.nodeValue;
+ currentNode.parentNode.insertBefore(node, currentNode);
+ currentNode.parentNode.removeChild(currentNode);
+ currentNode = node;
+ wasHighlighted = true;
+ } else {
+ wasHighlighted = false;
+ }
+
+ if (currentNode.childNodes.length > 0 && !this.isOsLineNumberNode(currentNode) && !wasHighlighted) {
+ currentNode = currentNode.childNodes[0];
+ } else if (currentNode.nextSibling) {
+ currentNode = currentNode.nextSibling;
+ } else {
+ currentNode = this.findNextAuntNode(currentNode);
+ }
+
+ if (this.isOsLineNumberNode(currentNode)) {
+ foundNextLineNumber = true;
+ }
+ } while (!foundNextLineNumber && currentNode !== null);
+ }
+
+ /**
+ * Highlights (span[class=highlight]) a specific line.
+ *
+ * @param {string} html
+ * @param {number} lineNumber
+ * @return {string}
+ */
+ public highlightLine(html: string, lineNumber: number): string {
+ const fragment = this.htmlToFragment(html),
+ lineNumberNode = this.getLineNumberNode(fragment, lineNumber);
+
+ if (lineNumberNode) {
+ this.highlightUntilNextLine(lineNumberNode);
+ html = this.fragmentToHtml(fragment);
+ }
+
+ return html;
+ }
+}
diff --git a/client/src/app/site/motions/services/motion-repository.service.ts b/client/src/app/site/motions/services/motion-repository.service.ts
index 0e2b2f902..4bc0e07dc 100644
--- a/client/src/app/site/motions/services/motion-repository.service.ts
+++ b/client/src/app/site/motions/services/motion-repository.service.ts
@@ -6,10 +6,15 @@ import { User } from '../../../shared/models/users/user';
import { Category } from '../../../shared/models/motions/category';
import { Workflow } from '../../../shared/models/motions/workflow';
import { WorkflowState } from '../../../shared/models/motions/workflow-state';
-import { ViewMotion } from '../models/view-motion';
+import { ChangeRecoMode, ViewMotion } from '../models/view-motion';
import { Observable } from 'rxjs';
import { BaseRepository } from '../../base/base-repository';
import { DataStoreService } from '../../../core/services/data-store.service';
+import { LinenumberingService } from './linenumbering.service';
+import { DiffService, LineRange, ModificationType } from './diff.service';
+import { ViewChangeReco } from '../models/view-change-reco';
+import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco';
+import { ViewUnifiedChange } from '../models/view-unified-change';
/**
* Repository Services for motions (and potentially categories)
@@ -30,9 +35,17 @@ export class MotionRepositoryService extends BaseRepository
*
* Converts existing and incoming motions to ViewMotions
* Handles CRUD using an observer to the DataStore
- * @param DataSend
+ * @param {DataStoreService} DS
+ * @param {DataSendService} dataSend
+ * @param {LinenumberingService} lineNumbering
+ * @param {DiffService} diff
*/
- public constructor(DS: DataStoreService, private dataSend: DataSendService) {
+ public constructor(
+ DS: DataStoreService,
+ private dataSend: DataSendService,
+ private readonly lineNumbering: LinenumberingService,
+ private readonly diff: DiffService
+ ) {
super(DS, Motion, [Category, User, Workflow]);
}
@@ -102,51 +115,209 @@ export class MotionRepositoryService extends BaseRepository
* Format the motion text using the line numbering and change
* reco algorithm.
*
- * TODO: Call DiffView and LineNumbering Service here.
- *
* Can be called from detail view and exporter
* @param id Motion ID - will be pulled from the repository
- * @param lnMode indicator for the line numbering mode
* @param crMode indicator for the change reco mode
+ * @param changes all change recommendations and amendments, sorted by line number
+ * @param lineLength the current line
+ * @param highlightLine the currently highlighted line (default: none)
*/
- public formatMotion(id: number, lnMode: number, crMode: number): string {
+ public formatMotion(
+ id: number,
+ crMode: ChangeRecoMode,
+ changes: ViewUnifiedChange[],
+ lineLength: number,
+ highlightLine?: number
+ ): string {
const targetMotion = this.getViewModel(id);
if (targetMotion && targetMotion.text) {
- let motionText = targetMotion.text;
-
- // TODO : Use Line numbering service here
- switch (lnMode) {
- case 0: // no line numbers
- break;
- case 1: // line number inside
- motionText = 'Get line numbers outside';
- break;
- case 2: // line number outside
- motionText = 'Get line numbers inside';
- break;
- }
-
- // TODO : Use Diff Service here.
- // this will(currently) append the previous changes.
- // update
switch (crMode) {
- case 0: // Original
- break;
- case 1: // Changed Version
- motionText += ' and get changed version';
- break;
- case 2: // Diff Version
- motionText += ' and get diff version';
- break;
- case 3: // Final Version
- motionText += ' and final version';
- break;
+ case ChangeRecoMode.Original:
+ return this.lineNumbering.insertLineNumbers(targetMotion.text, lineLength, highlightLine);
+ case ChangeRecoMode.Changed:
+ return this.diff.getTextWithChanges(targetMotion, changes, lineLength, highlightLine);
+ case ChangeRecoMode.Diff:
+ let text = '';
+ changes.forEach((change: ViewUnifiedChange, idx: number) => {
+ if (idx === 0) {
+ text += this.extractMotionLineRange(
+ id,
+ {
+ from: 1,
+ to: change.getLineFrom()
+ },
+ true
+ );
+ } else if (changes[idx - 1].getLineTo() < change.getLineFrom()) {
+ text += this.extractMotionLineRange(
+ id,
+ {
+ from: changes[idx - 1].getLineTo(),
+ to: change.getLineFrom()
+ },
+ true
+ );
+ }
+ text += this.getChangeDiff(targetMotion, change, highlightLine);
+ });
+ text += this.getTextRemainderAfterLastChange(targetMotion, changes, highlightLine);
+ return text;
+ case ChangeRecoMode.Final:
+ const appliedChanges: ViewUnifiedChange[] = changes.filter(change => change.isAccepted());
+ return this.diff.getTextWithChanges(targetMotion, appliedChanges, lineLength, highlightLine);
+ default:
+ console.error('unrecognized ChangeRecoMode option');
+ return null;
}
-
- return motionText;
} else {
return null;
}
}
+
+ /**
+ * Extracts a renderable HTML string representing the given line number range of this motion
+ *
+ * @param {number} id
+ * @param {LineRange} lineRange
+ * @param {boolean} lineNumbers - weather to add line numbers to the returned HTML string
+ */
+ public extractMotionLineRange(id: number, lineRange: LineRange, lineNumbers: boolean): string {
+ // @TODO flexible line numbers
+ const origHtml = this.formatMotion(id, ChangeRecoMode.Original, [], 80);
+ const extracted = this.diff.extractRangeByLineNumbers(origHtml, lineRange.from, lineRange.to);
+ let html =
+ extracted.outerContextStart +
+ extracted.innerContextStart +
+ extracted.html +
+ extracted.innerContextEnd +
+ extracted.outerContextEnd;
+ if (lineNumbers) {
+ html = this.lineNumbering.insertLineNumbers(html, 80, null, null, lineRange.from);
+ }
+ return html;
+ }
+
+ /**
+ * Returns the remainder text of the motion after the last change
+ *
+ * @param {ViewMotion} motion
+ * @param {ViewUnifiedChange[]} changes
+ * @param {number} highlight
+ */
+ public getTextRemainderAfterLastChange(
+ motion: ViewMotion,
+ changes: ViewUnifiedChange[],
+ highlight?: number
+ ): string {
+ let maxLine = 0;
+ changes.forEach((change: ViewUnifiedChange) => {
+ if (change.getLineTo() > maxLine) {
+ maxLine = change.getLineTo();
+ }
+ }, 0);
+
+ const numberedHtml = this.lineNumbering.insertLineNumbers(motion.text, motion.lineLength);
+ let data;
+
+ try {
+ data = this.diff.extractRangeByLineNumbers(numberedHtml, maxLine, null);
+ } catch (e) {
+ // This only happens (as far as we know) when the motion text has been altered (shortened)
+ // without modifying the change recommendations accordingly.
+ // That's a pretty serious inconsistency that should not happen at all,
+ // we're just doing some basic damage control here.
+ const msg =
+ 'Inconsistent data. A change recommendation is probably referring to a non-existant line number.';
+ return '' + msg + '';
+ }
+
+ let html;
+ if (data.html !== '') {
+ // Add "merge-before"-css-class if the first line begins in the middle of a paragraph. Used for PDF.
+ html =
+ this.diff.addCSSClassToFirstTag(data.outerContextStart + data.innerContextStart, 'merge-before') +
+ data.html +
+ data.innerContextEnd +
+ data.outerContextEnd;
+ html = this.lineNumbering.insertLineNumbers(html, motion.lineLength, highlight, null, maxLine);
+ } else {
+ // Prevents empty lines at the end of the motion
+ html = '';
+ }
+ return html;
+ }
+
+ /**
+ * Creates a {@link ViewChangeReco} object based on the motion ID and the given lange range.
+ * This object is not saved yet and does not yet have any changed HTML. It's meant to populate the UI form.
+ *
+ * @param {number} motionId
+ * @param {LineRange} lineRange
+ */
+ public createChangeRecommendationTemplate(motionId: number, lineRange: LineRange): ViewChangeReco {
+ const changeReco = new MotionChangeReco();
+ changeReco.line_from = lineRange.from;
+ changeReco.line_to = lineRange.to;
+ changeReco.type = ModificationType.TYPE_REPLACEMENT;
+ changeReco.text = this.extractMotionLineRange(motionId, lineRange, false);
+ changeReco.rejected = false;
+ changeReco.motion_id = motionId;
+
+ return new ViewChangeReco(changeReco);
+ }
+
+ /**
+ * Returns the HTML with the changes, optionally with a highlighted line.
+ * The original motion needs to be provided.
+ *
+ * @param {ViewMotion} motion
+ * @param {ViewUnifiedChange} change
+ * @param {number} highlight
+ */
+ public getChangeDiff(motion: ViewMotion, change: ViewUnifiedChange, highlight?: number): string {
+ const lineLength = motion.lineLength,
+ html = this.lineNumbering.insertLineNumbers(motion.text, lineLength);
+
+ let data, oldText;
+
+ try {
+ data = this.diff.extractRangeByLineNumbers(html, change.getLineFrom(), change.getLineTo());
+ oldText =
+ data.outerContextStart +
+ data.innerContextStart +
+ data.html +
+ data.innerContextEnd +
+ data.outerContextEnd;
+ } catch (e) {
+ // This only happens (as far as we know) when the motion text has been altered (shortened)
+ // without modifying the change recommendations accordingly.
+ // That's a pretty serious inconsistency that should not happen at all,
+ // we're just doing some basic damage control here.
+ const msg =
+ 'Inconsistent data. A change recommendation is probably referring to a non-existant line number.';
+ return '' + msg + '';
+ }
+
+ oldText = this.lineNumbering.insertLineNumbers(oldText, lineLength, null, null, change.getLineFrom());
+ let diff = this.diff.diff(oldText, change.getChangeNewText());
+
+ // If an insertion makes the line longer than the line length limit, we need two line breaking runs:
+ // - First, for the official line numbers, ignoring insertions (that's been done some lines before)
+ // - Second, another one to prevent the displayed including insertions to exceed the page width
+ diff = this.lineNumbering.insertLineBreaksWithoutNumbers(diff, lineLength, true);
+
+ if (highlight > 0) {
+ diff = this.lineNumbering.highlightLine(diff, highlight);
+ }
+
+ const origBeginning = data.outerContextStart + data.innerContextStart;
+ if (diff.toLowerCase().indexOf(origBeginning.toLowerCase()) === 0) {
+ // Add "merge-before"-css-class if the first line begins in the middle of a paragraph. Used for PDF.
+ diff =
+ this.diff.addCSSClassToFirstTag(origBeginning, 'merge-before') + diff.substring(origBeginning.length);
+ }
+
+ return diff;
+ }
}