@contember/react-slate-editor-base 2.0.3 → 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@contember/react-slate-editor-base",
3
3
  "license": "Apache-2.0",
4
- "version": "2.0.3",
4
+ "version": "2.0.7",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "main": "./dist/production/index.js",
@@ -30,12 +30,12 @@
30
30
  },
31
31
  "repository": {
32
32
  "type": "git",
33
- "url": "https://github.com/contember/interface.git",
33
+ "url": "git+https://github.com/contember/contember.git",
34
34
  "directory": "packages/react-slate-editor-base"
35
35
  },
36
36
  "dependencies": {
37
- "@contember/react-binding": "2.0.3",
38
- "@contember/utilities": "2.0.3",
37
+ "@contember/react-binding": "2.0.7",
38
+ "@contember/utilities": "2.0.7",
39
39
  "@radix-ui/primitive": "^1.1.0",
40
40
  "@radix-ui/react-slot": "^1.1.0",
41
41
  "is-hotkey": "^0.2.0",
@@ -83,7 +83,7 @@ export class HtmlDeserializer {
83
83
  return {
84
84
  elements: processed.flatMap(item => {
85
85
  if (item.text !== undefined) {
86
- return item.isWhiteSpace ? [] : [this.createDefaultElement([item.text])]
86
+ return item.isWhiteSpace ? [] : [this.createDefaultElement(this.trimBlockBoundaryWhitespace([item.text]))]
87
87
  } else if (item.element !== undefined) {
88
88
  return [item.element]
89
89
  }
@@ -111,7 +111,40 @@ export class HtmlDeserializer {
111
111
 
112
112
  deserializeBlocks(list: Node[], cumulativeTextAttrs: TextAttrs): Descendant[] {
113
113
  const result = this.processNodeListPaste(list, cumulativeTextAttrs)
114
- return result?.texts ?? result?.elements ?? []
114
+ if (result?.texts !== undefined) {
115
+ return this.trimBlockBoundaryWhitespace(result.texts)
116
+ }
117
+ return result?.elements ?? []
118
+ }
119
+
120
+ // Collapsible whitespace at the start/end of a block renders as nothing in HTML,
121
+ // so strip it from the edges of a block's inline content. Non-breaking spaces are kept.
122
+ private trimBlockBoundaryWhitespace(texts: Descendant[]): Descendant[] {
123
+ const trimmed = [...texts]
124
+ const trimEdge = (direction: 'start' | 'end') => {
125
+ for (let i = direction === 'start' ? 0 : trimmed.length - 1; i >= 0 && i < trimmed.length;) {
126
+ const node = trimmed[i]
127
+ if (!SlateText.isText(node)) {
128
+ return
129
+ }
130
+ const text = direction === 'start' ? node.text.replace(/^[ \t\r\n]+/, '') : node.text.replace(/[ \t\r\n]+$/, '')
131
+ if (text !== '') {
132
+ trimmed[i] = { ...node, text }
133
+ return
134
+ }
135
+ if (trimmed.length === 1) {
136
+ trimmed[i] = { ...node, text }
137
+ return
138
+ }
139
+ trimmed.splice(i, 1)
140
+ if (direction === 'end') {
141
+ i--
142
+ }
143
+ }
144
+ }
145
+ trimEdge('start')
146
+ trimEdge('end')
147
+ return trimmed
115
148
  }
116
149
 
117
150
 
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { Descendant, Element as SlateElement, Text as SlateText } from 'slate'
3
+ import { HtmlDeserializer } from '../../src/plugins/behaviour/paste/HtmlDeserializer'
4
+ import { paragraphHtmlDeserializer } from '../../src/plugins/element/paragraphs/ParagraphHtmlDeserializer'
5
+
6
+ const paragraph = (children: Descendant[]): SlateElement => ({ type: 'paragraph', children } as SlateElement)
7
+
8
+ const createDeserializer = () => new HtmlDeserializer(
9
+ children => paragraph(children),
10
+ [paragraphHtmlDeserializer],
11
+ )
12
+
13
+ const deserialize = (html: string) => {
14
+ const document = new DOMParser().parseFromString(html, 'text/html')
15
+ return createDeserializer().deserializeBlocks(Array.from(document.body.childNodes), {})
16
+ }
17
+
18
+ const childrenText = (node: unknown): string => {
19
+ if (SlateText.isText(node)) {
20
+ return node.text
21
+ }
22
+ return (node as SlateElement).children.map(childrenText).join('')
23
+ }
24
+
25
+ describe('html deserializer', () => {
26
+ it('trims block boundary whitespace in LibreOffice paste', () => {
27
+ const html = '<p style="line-height: 100%; margin-bottom: 0cm">\nFirst.</p>\n'
28
+ + '<p style="line-height: 100%; margin-bottom: 0cm"><br>\n\n</p>\n'
29
+ + '<p style="line-height: 100%; margin-bottom: 0cm">Second.</p>'
30
+ expect(deserialize(html)).toEqual([
31
+ paragraph([{ text: 'First.' }]),
32
+ paragraph([{ text: '' }]),
33
+ paragraph([{ text: 'Second.' }]),
34
+ ])
35
+ })
36
+
37
+ it('trims leading and trailing whitespace inside a paragraph', () => {
38
+ expect(deserialize('<p>\n\tfoo\n</p>')).toEqual([
39
+ paragraph([{ text: 'foo' }]),
40
+ ])
41
+ })
42
+
43
+ it('preserves inner whitespace around inline elements', () => {
44
+ const result = deserialize('<p>foo <span>bar</span> baz</p>')
45
+ expect(result).toHaveLength(1)
46
+ expect(childrenText(result[0])).toBe('foo bar baz')
47
+ expect((result[0] as SlateElement).children).toEqual([
48
+ { text: 'foo ' },
49
+ { text: 'bar' },
50
+ { text: ' baz' },
51
+ ])
52
+ })
53
+
54
+ it('maps <br> to a single space and keeps a lone <br> paragraph', () => {
55
+ const result = deserialize('<p>foo<br>bar</p>')
56
+ expect(result).toHaveLength(1)
57
+ expect(childrenText(result[0])).toBe('foo bar')
58
+
59
+ expect(deserialize('<p><br></p>')).toEqual([
60
+ paragraph([{ text: '' }]),
61
+ ])
62
+ })
63
+
64
+ it('keeps non-breaking space at block edge', () => {
65
+ const result = deserialize('<p>&nbsp;foo</p>')
66
+ expect(result).toHaveLength(1)
67
+ expect(childrenText(result[0])).toBe(' foo')
68
+ })
69
+
70
+ it('does not produce extra elements from whitespace between paragraphs', () => {
71
+ const result = deserialize('<p>a</p>\n<p>b</p>')
72
+ expect(result).toHaveLength(2)
73
+ expect(result).toEqual([
74
+ paragraph([{ text: 'a' }]),
75
+ paragraph([{ text: 'b' }]),
76
+ ])
77
+ })
78
+ })
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../../tsconfig.settings.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "emitDeclarationOnly": false
6
+ },
7
+ "references": [
8
+ { "path": "../src" },
9
+ ]
10
+ }