@kubex/zinc 1.1.181 → 1.1.183
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/dist/custom-elements.json +366 -350
- package/dist/vscode.html-custom-data.json +52 -52
- package/dist/web-types.json +127 -127
- package/dist/zn.d.ts +10 -2
- package/dist/zn.min.js +75 -75
- package/docs/pages/components/content-block.md +47 -0
- package/package.json +1 -1
- package/src/components/cols/cols.component.ts +30 -1
- package/src/components/cols/cols.scss +4 -0
- package/src/components/cols/cols.test.ts +31 -0
- package/src/components/content-block/content-block.component.ts +21 -7
- package/src/components/content-block/content-block.scss +36 -0
- package/src/components/content-block/content-block.test.ts +25 -0
- package/src/components/stat/stat.component.ts +2 -2
- package/src/components/stat/stat.scss +13 -1
- package/src/utilities/sanitize-html.ts +11 -2
|
@@ -52,6 +52,53 @@ layout: component
|
|
|
52
52
|
</zn-content-block>
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
### Editor Formatting
|
|
56
|
+
|
|
57
|
+
Text that holds escaped HTML, such as a note saved from `zn-editor`, is sanitized and rendered with the
|
|
58
|
+
editor's formatting. Only the text and background colour survive from a `style` attribute.
|
|
59
|
+
|
|
60
|
+
```html:preview
|
|
61
|
+
|
|
62
|
+
<zn-content-block
|
|
63
|
+
time="16 Jun 2025, 13:00"
|
|
64
|
+
sender="John Smith"
|
|
65
|
+
avatar="JS"
|
|
66
|
+
>
|
|
67
|
+
<div slot="text">
|
|
68
|
+
<p><strong>Bold</strong></p>
|
|
69
|
+
<p><em>Italic</em></p>
|
|
70
|
+
<p><u>Underline</u></p>
|
|
71
|
+
<p><s>Strikethrough</s></p>
|
|
72
|
+
<blockquote>Blockquote</blockquote>
|
|
73
|
+
<p><code>Inline code</code></p>
|
|
74
|
+
<pre data-language=plain>Code block</pre>
|
|
75
|
+
<p><span style=color:red>Coloured text</span></p>
|
|
76
|
+
<ul><li>Bullet</li><li>Bullet<ul><li>Nested bullet</li></ul></li></ul>
|
|
77
|
+
<ol><li>Numbered<ol><li>Nested numbered</li><li>Nested numbered</li></ol></li></ol>
|
|
78
|
+
<ul><li data-list=checked>Checked</li><li data-list=unchecked>Unchecked</li></ul>
|
|
79
|
+
<hr class=ql-hr>
|
|
80
|
+
<p>Divider above</p>
|
|
81
|
+
<p><a href=https://example.com>Link</a></p>
|
|
82
|
+
</div>
|
|
83
|
+
<div slot="html">
|
|
84
|
+
<p><strong>Bold</strong></p>
|
|
85
|
+
<p><em>Italic</em></p>
|
|
86
|
+
<p><u>Underline</u></p>
|
|
87
|
+
<p><s>Strikethrough</s></p>
|
|
88
|
+
<blockquote>Blockquote</blockquote>
|
|
89
|
+
<p><code>Inline code</code></p>
|
|
90
|
+
<pre data-language="plain">Code block</pre>
|
|
91
|
+
<p><span style="color:red">Coloured text</span></p>
|
|
92
|
+
<ul><li>Bullet</li><li>Bullet<ul><li>Nested bullet</li></ul></li></ul>
|
|
93
|
+
<ol><li>Numbered<ol><li>Nested numbered</li><li>Nested numbered</li></ol></li></ol>
|
|
94
|
+
<ul><li data-list="checked">Checked</li><li data-list="unchecked">Unchecked</li></ul>
|
|
95
|
+
<hr class="ql-hr">
|
|
96
|
+
<p>Divider above</p>
|
|
97
|
+
<p><a href="https://example.com">Link</a></p>
|
|
98
|
+
</div>
|
|
99
|
+
</zn-content-block>
|
|
100
|
+
```
|
|
101
|
+
|
|
55
102
|
### Attachments
|
|
56
103
|
|
|
57
104
|
Attachments render in a row at the bottom of the content and are only visible while the block
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {classMap} from "lit/directives/class-map.js";
|
|
2
|
-
import {type CSSResultGroup, html, unsafeCSS} from 'lit';
|
|
2
|
+
import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
|
|
3
3
|
import {property} from 'lit/decorators.js';
|
|
4
4
|
import ZincElement from '../../internal/zinc-element';
|
|
5
5
|
|
|
@@ -65,14 +65,43 @@ export default class ZnCols extends ZincElement {
|
|
|
65
65
|
// the whole subtree, to avoid reacting to unrelated content updates deeper in a column.
|
|
66
66
|
private readonly childObserver: MutationObserver = new MutationObserver(() => this.requestUpdate());
|
|
67
67
|
|
|
68
|
+
private readonly resizeObserver: ResizeObserver = new ResizeObserver(() => this.markRowEnds());
|
|
69
|
+
|
|
68
70
|
connectedCallback() {
|
|
69
71
|
super.connectedCallback();
|
|
70
72
|
this.childObserver.observe(this, {childList: true});
|
|
73
|
+
this.resizeObserver.observe(this);
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
disconnectedCallback() {
|
|
74
77
|
super.disconnectedCallback();
|
|
75
78
|
this.childObserver.disconnect();
|
|
79
|
+
this.resizeObserver.disconnect();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected updated(changed: PropertyValues<this>) {
|
|
83
|
+
super.updated(changed);
|
|
84
|
+
this.markRowEnds();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Flags each child that ends its row as laid out, so `divide` only draws between neighbours on the same row
|
|
89
|
+
* however the columns have wrapped.
|
|
90
|
+
*/
|
|
91
|
+
private markRowEnds() {
|
|
92
|
+
const children = Array.from(this.querySelectorAll(':scope > *:not([slot])'));
|
|
93
|
+
if (!this.divide) {
|
|
94
|
+
children.forEach((child) => child.removeAttribute('data-row-end'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rects = children.map((child) => child.getBoundingClientRect());
|
|
99
|
+
children.forEach((child, i) => {
|
|
100
|
+
const rect = rects[i];
|
|
101
|
+
const rowEnd = !rects.some((other) =>
|
|
102
|
+
other !== rect && other.top < rect.bottom && other.bottom > rect.top && other.left > rect.left);
|
|
103
|
+
child.toggleAttribute('data-row-end', rowEnd);
|
|
104
|
+
});
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
/**
|
|
@@ -135,4 +135,35 @@ describe('<zn-cols>', () => {
|
|
|
135
135
|
expect(orderOf(el.querySelector('#usage'))).to.equal('-1');
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
describe('dividers', () => {
|
|
140
|
+
const dividerOf = (el: Element | null) => getComputedStyle(el!, '::after').display;
|
|
141
|
+
|
|
142
|
+
it('should only divide neighbours on the same row', async () => {
|
|
143
|
+
const el = await cols(`
|
|
144
|
+
<zn-cols layout="1,1" divide style="width: 600px">
|
|
145
|
+
<div id="a" style="height: 20px"></div>
|
|
146
|
+
<div id="b" style="height: 20px"></div>
|
|
147
|
+
<div id="c" style="height: 20px"></div>
|
|
148
|
+
<div id="d" style="height: 20px"></div>
|
|
149
|
+
</zn-cols>`);
|
|
150
|
+
|
|
151
|
+
expect(dividerOf(el.querySelector('#a'))).to.equal('block');
|
|
152
|
+
expect(dividerOf(el.querySelector('#b'))).to.equal('none');
|
|
153
|
+
expect(dividerOf(el.querySelector('#c'))).to.equal('block');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should drop the dividers once the columns wrap', async () => {
|
|
157
|
+
const el = await cols(`
|
|
158
|
+
<zn-cols layout="1,1" divide style="width: 600px">
|
|
159
|
+
<div id="a" style="height: 20px"></div>
|
|
160
|
+
<div id="b" style="height: 20px"></div>
|
|
161
|
+
</zn-cols>`);
|
|
162
|
+
|
|
163
|
+
el.style.width = '400px';
|
|
164
|
+
await aTimeout(100);
|
|
165
|
+
|
|
166
|
+
expect(dividerOf(el.querySelector('#a'))).to.equal('none');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
138
169
|
});
|
|
@@ -13,6 +13,21 @@ import type ZnTile from "../tile";
|
|
|
13
13
|
|
|
14
14
|
import styles from './content-block.scss';
|
|
15
15
|
|
|
16
|
+
// What the editor (Quill) can produce beyond the sanitizer's defaults
|
|
17
|
+
const EDITOR_TAGS = ['s', 'code', 'pre', 'blockquote', 'hr', 'span'];
|
|
18
|
+
|
|
19
|
+
// The iframe can't see the shadow styles, so editor formatting is restyled to match the text view
|
|
20
|
+
const IFRAME_STYLES = 'html,body{background-color:#fff;margin:0}img{max-width:100%;max-height:500px}'
|
|
21
|
+
+ 'p{margin:0}ul,ol{margin:0;padding-left:1.5em}'
|
|
22
|
+
+ 'blockquote{border-left:4px solid #ddd;margin:5px 0;padding-left:16px}'
|
|
23
|
+
+ 'code{background-color:#f0f0f0;border-radius:3px;font-size:85%;padding:2px 4px}'
|
|
24
|
+
+ 'pre{background-color:#23241f;border-radius:3px;margin:5px 0;padding:5px 10px;color:#f8f8f2;overflow:auto}'
|
|
25
|
+
+ 'pre code{background:none;padding:0}'
|
|
26
|
+
+ 'hr{border:none;border-top:1px solid #ddd;margin:8px 0}'
|
|
27
|
+
+ 'li[data-list=checked],li[data-list=unchecked]{list-style:none}'
|
|
28
|
+
+ 'li[data-list=checked]::before{content:"☑";color:#777;margin:0 .3em 0 -1.5em}'
|
|
29
|
+
+ 'li[data-list=unchecked]::before{content:"☐";color:#777;margin:0 .3em 0 -1.5em}';
|
|
30
|
+
|
|
16
31
|
interface TextRow {
|
|
17
32
|
lines: string[];
|
|
18
33
|
type: 'reply' | 'text';
|
|
@@ -70,14 +85,13 @@ export default class ContentBlock extends ZincElement {
|
|
|
70
85
|
if (iframe.contentDocument) {
|
|
71
86
|
const div = this.htmlNodes[0] as HTMLDivElement;
|
|
72
87
|
if (div) {
|
|
73
|
-
|
|
74
|
-
convertedHtml =
|
|
75
|
-
convertedHtml = convertedHtml.replace(/</g, '<');
|
|
76
|
-
convertedHtml = convertedHtml.replace(/>/g, '>');
|
|
88
|
+
// Escaped source has no element children; textContent decodes it once, including &nbsp;
|
|
89
|
+
let convertedHtml = div.children.length ? div.innerHTML : div.textContent ?? '';
|
|
77
90
|
convertedHtml = convertedHtml.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
|
78
91
|
|
|
79
|
-
const
|
|
80
|
-
iframe.srcdoc =
|
|
92
|
+
const {fontFamily, fontSize, lineHeight, color} = getComputedStyle(this.shadowRoot?.querySelector('.text-section') ?? this);
|
|
93
|
+
iframe.srcdoc = `<style>body{font-family:${fontFamily};font-size:${fontSize};line-height:${lineHeight};color:${color}}${IFRAME_STYLES}</style>`
|
|
94
|
+
+ convertedHtml;
|
|
81
95
|
}
|
|
82
96
|
}
|
|
83
97
|
|
|
@@ -366,7 +380,7 @@ export default class ContentBlock extends ZincElement {
|
|
|
366
380
|
const textRows: TextRow[] = [];
|
|
367
381
|
|
|
368
382
|
if (textContent && looksLikeHtml(textContent.innerText)) {
|
|
369
|
-
this._textRows = [{lines: [sanitizeHtml(textContent.innerText)], type: 'text', html: true}];
|
|
383
|
+
this._textRows = [{lines: [sanitizeHtml(textContent.innerText, EDITOR_TAGS, true)], type: 'text', html: true}];
|
|
370
384
|
return this._textRows;
|
|
371
385
|
}
|
|
372
386
|
|
|
@@ -118,6 +118,38 @@
|
|
|
118
118
|
font-size: var(--zn-font-size-small);
|
|
119
119
|
opacity: 0.8;
|
|
120
120
|
}
|
|
121
|
+
|
|
122
|
+
p {
|
|
123
|
+
margin: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
blockquote {
|
|
127
|
+
border-left: 4px solid rgb(var(--zn-border-color));
|
|
128
|
+
margin: 5px 0;
|
|
129
|
+
padding-left: 16px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
code {
|
|
133
|
+
background-color: #f0f0f0;
|
|
134
|
+
border-radius: 3px;
|
|
135
|
+
font-size: 85%;
|
|
136
|
+
padding: 2px 4px;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pre {
|
|
140
|
+
background-color: #23241f;
|
|
141
|
+
border-radius: 3px;
|
|
142
|
+
margin-block: 5px;
|
|
143
|
+
padding: 5px 10px;
|
|
144
|
+
color: #f8f8f2;
|
|
145
|
+
overflow: auto;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
hr {
|
|
149
|
+
border: none;
|
|
150
|
+
border-top: 1px solid rgb(var(--zn-border-color));
|
|
151
|
+
margin: var(--zn-spacing-small) 0;
|
|
152
|
+
}
|
|
121
153
|
}
|
|
122
154
|
|
|
123
155
|
.hidden {
|
|
@@ -139,6 +171,10 @@ ol, ul {
|
|
|
139
171
|
margin: 0;
|
|
140
172
|
}
|
|
141
173
|
|
|
174
|
+
li > ol, li > ul {
|
|
175
|
+
padding-left: 1.5em;
|
|
176
|
+
}
|
|
177
|
+
|
|
142
178
|
li[data-list=checked] > .ql-ui, li[data-list=unchecked] > .ql-ui {
|
|
143
179
|
color: #777;
|
|
144
180
|
}
|
|
@@ -17,6 +17,16 @@ describe('<zn-content-block>', () => {
|
|
|
17
17
|
expect(el).to.exist;
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
it('should decode escaped source in the html body once', async () => {
|
|
21
|
+
const el = await fixture<LitElement>(
|
|
22
|
+
'<zn-content-block><div slot="html"><p>Test&nbsp;Format</p></div></zn-content-block>'
|
|
23
|
+
);
|
|
24
|
+
await el.updateComplete;
|
|
25
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
26
|
+
|
|
27
|
+
expect(iframe.srcdoc).to.contain('<p>Test Format</p>');
|
|
28
|
+
});
|
|
29
|
+
|
|
20
30
|
it('should render plain text in the text body as text', async () => {
|
|
21
31
|
const content = await textSection('1 < 2 && 3 > 2');
|
|
22
32
|
|
|
@@ -45,6 +55,21 @@ describe('<zn-content-block>', () => {
|
|
|
45
55
|
expect(content.textContent).to.not.contain('<p>');
|
|
46
56
|
});
|
|
47
57
|
|
|
58
|
+
it('should keep editor formatting and only colours from styles in the text body', async () => {
|
|
59
|
+
const content = await textSection(
|
|
60
|
+
'<p><s>struck</s> <code>inline</code></p><blockquote>quote</blockquote>'
|
|
61
|
+
+ '<pre>block</pre><hr>'
|
|
62
|
+
+ '<p><span style="color: red; background-image: url(https://x.test/p.gif)">red</span></p>'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(content.querySelector('s')?.textContent).to.equal('struck');
|
|
66
|
+
expect(content.querySelector('code')?.textContent).to.equal('inline');
|
|
67
|
+
expect(content.querySelector('blockquote')?.textContent).to.equal('quote');
|
|
68
|
+
expect(content.querySelector('pre')?.textContent).to.equal('block');
|
|
69
|
+
expect(content.querySelector('hr')).to.exist;
|
|
70
|
+
expect(content.querySelector('span')!.getAttribute('style')).to.equal('color: red;');
|
|
71
|
+
});
|
|
72
|
+
|
|
48
73
|
it('should strip unsafe links and dropped elements from markup in the text body', async () => {
|
|
49
74
|
const content = await textSection(
|
|
50
75
|
'<p><a href="JavaScript:alert(1)">click</a></p>'
|
|
@@ -127,8 +127,8 @@ export default class ZnStatsTile extends ZincElement {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
private renderAmount() {
|
|
130
|
-
return html
|
|
131
|
-
<zn-icon class="icon" src="${this.icon}" size="20" color="${ifDefined(this.iconColor)}"></zn-icon>` : null}
|
|
130
|
+
return html`<span class="value">${this.getDisplayAmount()}${this.icon ? html`
|
|
131
|
+
<zn-icon class="icon" src="${this.icon}" size="20" color="${ifDefined(this.iconColor)}"></zn-icon>` : null}</span>`;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
private renderValueFirst() {
|
|
@@ -73,6 +73,12 @@
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
.value {
|
|
77
|
+
display: inline-flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
white-space: nowrap;
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
.icon {
|
|
77
83
|
margin-left: var(--zn-spacing-x-small);
|
|
78
84
|
}
|
|
@@ -89,13 +95,19 @@
|
|
|
89
95
|
|
|
90
96
|
.amount {
|
|
91
97
|
flex-grow: 0;
|
|
92
|
-
|
|
98
|
+
flex-wrap: wrap;
|
|
99
|
+
column-gap: var(--zn-spacing-x-small);
|
|
93
100
|
color: inherit;
|
|
94
101
|
}
|
|
95
102
|
|
|
103
|
+
.value {
|
|
104
|
+
gap: var(--zn-spacing-x-small);
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
.diff {
|
|
97
108
|
font-size: var(--zn-font-size-small);
|
|
98
109
|
font-weight: 400;
|
|
110
|
+
white-space: nowrap;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
.diff .neutral,
|
|
@@ -16,11 +16,12 @@ const DROPPED = [
|
|
|
16
16
|
/**
|
|
17
17
|
* Sanitizes untrusted HTML down to formatting tags, keeping only a link's href when it is http(s) or mailto.
|
|
18
18
|
* Tags in `keep` are allowed as they are, so a block such as `pre` isn't turned into a paragraph.
|
|
19
|
+
* With `colors`, a `style` attribute survives holding only its text and background colour.
|
|
19
20
|
*/
|
|
20
|
-
export function sanitizeHtml(source: string, keep: string[] = []): string {
|
|
21
|
+
export function sanitizeHtml(source: string, keep: string[] = [], colors = false): string {
|
|
21
22
|
const fragment = DOMPurify.sanitize(source, {
|
|
22
23
|
ALLOWED_TAGS: [...FORMATTING, ...BLOCKS, ...keep],
|
|
23
|
-
ALLOWED_ATTR: ['href'],
|
|
24
|
+
ALLOWED_ATTR: colors ? ['href', 'style'] : ['href'],
|
|
24
25
|
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
|
|
25
26
|
FORBID_CONTENTS: DROPPED,
|
|
26
27
|
RETURN_DOM_FRAGMENT: true,
|
|
@@ -36,6 +37,14 @@ export function sanitizeHtml(source: string, keep: string[] = []): string {
|
|
|
36
37
|
fragment.querySelectorAll('p').forEach(p => {
|
|
37
38
|
if (!p.textContent?.trim()) p.remove();
|
|
38
39
|
});
|
|
40
|
+
// Any other declaration could load a remote url(), which in an email is a tracking pixel
|
|
41
|
+
fragment.querySelectorAll<HTMLElement>('[style]').forEach(el => {
|
|
42
|
+
const {color, backgroundColor} = el.style;
|
|
43
|
+
el.removeAttribute('style');
|
|
44
|
+
el.style.color = color;
|
|
45
|
+
el.style.backgroundColor = backgroundColor;
|
|
46
|
+
if (!el.getAttribute('style')) el.removeAttribute('style');
|
|
47
|
+
});
|
|
39
48
|
fragment.querySelectorAll('a[href]').forEach(link => {
|
|
40
49
|
try {
|
|
41
50
|
link.setAttribute('href', new URL(link.getAttribute('href')!.trim()).href);
|