@svgrid/grid 2.6.21 → 2.6.23
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/CHANGELOG.md +62 -0
- package/README.md +22 -0
- package/dist/GridMenus.svelte +201 -18
- package/dist/SvAutoComplete.svelte +4 -0
- package/dist/SvComboBox.svelte +5 -1
- package/dist/SvDateRangeInput.svelte +25 -3
- package/dist/SvDropDownList.svelte +4 -0
- package/dist/SvForm.svelte +8 -8
- package/dist/SvGrid.controller.svelte.d.ts +61 -9
- package/dist/SvGrid.controller.svelte.js +363 -123
- package/dist/SvGrid.css +222 -1
- package/dist/SvGrid.helpers.d.ts +55 -0
- package/dist/SvGrid.helpers.js +83 -0
- package/dist/SvGrid.svelte +255 -56
- package/dist/SvGrid.types.d.ts +146 -1
- package/dist/SvGridCellEditor.svelte +99 -17
- package/dist/SvGridSelect.svelte +6 -2
- package/dist/SvGridSelect.svelte.d.ts +1 -1
- package/dist/SvListBox.svelte +8 -0
- package/dist/SvMultiSelect.svelte +6 -2
- package/dist/SvMultiSelect.svelte.d.ts +1 -1
- package/dist/SvNumberInput.svelte +4 -0
- package/dist/SvRichCell.svelte +125 -0
- package/dist/SvRichCell.svelte.d.ts +15 -0
- package/dist/SvTextArea.svelte +5 -1
- package/dist/SvTreeSelect.svelte +6 -2
- package/dist/SvTreeSelect.svelte.d.ts +1 -1
- package/dist/builtin-editors.d.ts +2 -2
- package/dist/builtin-editors.js +19 -3
- package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
- package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
- package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
- package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
- package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
- package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
- package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
- package/dist/cdn/column-resize-DsfNXMom.js +102 -0
- package/dist/cdn/date-format-BNii4zeD.js +1393 -0
- package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
- package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
- package/dist/cdn/row-resize-BRcimkUT.js +95 -0
- package/dist/cdn/{src-DBel9wRZ.js → src-DGo7IHug.js} +10017 -9488
- package/dist/cdn/{src-BYq-qyrp.js → src-skQN5eqh.js} +7829 -7300
- package/dist/cdn/svgrid.js +14 -10
- package/dist/cdn/svgrid.svelte-external.js +14 -10
- package/dist/cell-values.d.ts +16 -0
- package/dist/cell-values.js +28 -0
- package/dist/clipboard.d.ts +22 -0
- package/dist/clipboard.js +405 -27
- package/dist/column-groups.js +1 -1
- package/dist/column-resize.d.ts +46 -0
- package/dist/column-resize.js +205 -0
- package/dist/columns.d.ts +0 -3
- package/dist/columns.js +0 -57
- package/dist/core.d.ts +19 -4
- package/dist/core.js +460 -119
- package/dist/editing.js +12 -7
- package/dist/editors/cell-editors.d.ts +11 -0
- package/dist/editors/cell-editors.js +18 -1
- package/dist/filtering/excel-filters.js +28 -0
- package/dist/grid-messages.d.ts +15 -0
- package/dist/grid-messages.js +15 -0
- package/dist/group-display.d.ts +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +10 -0
- package/dist/list-option.d.ts +6 -0
- package/dist/menus.js +41 -8
- package/dist/row-resize.d.ts +11 -0
- package/dist/row-resize.js +7 -1
- package/dist/sanitize-html.d.ts +37 -0
- package/dist/sanitize-html.js +234 -0
- package/dist/selection-bar-view.svelte.d.ts +25 -0
- package/dist/selection-bar-view.svelte.js +13 -0
- package/dist/selection.d.ts +2 -1
- package/dist/selection.js +133 -8
- package/dist/spreadsheet.d.ts +1 -1
- package/dist/spreadsheet.js +1 -1
- package/package.json +1 -1
- package/src/GridMenus.svelte +201 -18
- package/src/SvAutoComplete.svelte +4 -0
- package/src/SvComboBox.svelte +5 -1
- package/src/SvDateRangeInput.svelte +25 -3
- package/src/SvDropDownList.svelte +4 -0
- package/src/SvForm.svelte +8 -8
- package/src/SvGrid.controller.svelte.ts +404 -133
- package/src/SvGrid.css +222 -1
- package/src/SvGrid.helpers.ts +87 -0
- package/src/SvGrid.svelte +255 -56
- package/src/SvGrid.types.ts +153 -1
- package/src/SvGridCellEditor.svelte +99 -17
- package/src/SvGridSelect.svelte +6 -2
- package/src/SvListBox.svelte +8 -0
- package/src/SvMultiSelect.svelte +6 -2
- package/src/SvNumberInput.svelte +4 -0
- package/src/SvRichCell.svelte +125 -0
- package/src/SvTextArea.svelte +5 -1
- package/src/SvTreeSelect.svelte +6 -2
- package/src/builtin-editors.test.ts +18 -1
- package/src/builtin-editors.ts +19 -3
- package/src/cell-values.ts +30 -0
- package/src/clipboard.test.ts +93 -20
- package/src/clipboard.ts +433 -33
- package/src/column-groups.ts +1 -1
- package/src/column-resize.test.ts +381 -0
- package/src/column-resize.ts +227 -0
- package/src/columns.test.ts +0 -103
- package/src/columns.ts +0 -58
- package/src/core.aggregate.test.ts +134 -0
- package/src/core.filter.test.ts +156 -0
- package/src/core.grouping.test.ts +146 -0
- package/src/core.row-shape.test.ts +119 -0
- package/src/core.rowmodel-cache.test.ts +121 -0
- package/src/core.sort.test.ts +293 -0
- package/src/core.ts +516 -119
- package/src/date-string-column.test.ts +87 -0
- package/src/editing.test.ts +25 -0
- package/src/editing.ts +12 -9
- package/src/editor-block-prop.test.ts +132 -0
- package/src/editors/cell-editors.ts +27 -1
- package/src/filtering/excel-filters.ts +30 -0
- package/src/filtering/normalize-fast-path.test.ts +104 -0
- package/src/grid-messages.ts +34 -0
- package/src/group-display.ts +1 -1
- package/src/index.ts +25 -1
- package/src/list-option.ts +6 -0
- package/src/menus.test.ts +43 -0
- package/src/menus.ts +38 -8
- package/src/move-cells.test.ts +850 -0
- package/src/number-editor-literal.test.ts +84 -0
- package/src/number-editor.grid.test.ts +160 -0
- package/src/resize-props.test.ts +361 -0
- package/src/row-resize.test.ts +31 -0
- package/src/row-resize.ts +21 -3
- package/src/sanitize-html.test.ts +182 -0
- package/src/sanitize-html.ts +235 -0
- package/src/selection-bar-view.svelte.ts +36 -0
- package/src/selection.test.ts +157 -0
- package/src/selection.ts +135 -8
- package/src/spreadsheet.ts +1 -1
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
- package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
- package/src/svgrid.menu-scroll-close.test.ts +204 -0
- package/src/svgrid.selection-bar-seam.test.ts +185 -0
- package/src/svgrid.upsell-license.test.ts +117 -0
- package/dist/cdn/GridMenus-BfTAKn84.js +0 -488
- package/dist/cdn/GridMenus-C3bJd7w8.js +0 -489
- package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
- package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
- package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
- package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { sanitizeHtml, renderMarkdown, htmlToText } from './sanitize-html'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The sanitizer is the one part of the rich-text cell that has to be right:
|
|
6
|
+
* everything else costs a rendering glitch, this costs script execution in the
|
|
7
|
+
* host page. The XSS cases below are the standard vectors a cell renderer gets
|
|
8
|
+
* hit with, so a regression here fails loudly.
|
|
9
|
+
*/
|
|
10
|
+
describe('sanitizeHtml - script execution vectors', () => {
|
|
11
|
+
it('drops script tags and their contents', () => {
|
|
12
|
+
const out = sanitizeHtml('<p>hi</p><script>alert(1)</script>')
|
|
13
|
+
expect(out).toContain('hi')
|
|
14
|
+
expect(out).not.toContain('script')
|
|
15
|
+
expect(out).not.toContain('alert')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('strips every inline event handler', () => {
|
|
19
|
+
for (const attr of ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus']) {
|
|
20
|
+
const out = sanitizeHtml(`<p ${attr}="alert(1)">x</p>`)
|
|
21
|
+
expect(out, attr).not.toContain(attr)
|
|
22
|
+
expect(out, attr).not.toContain('alert')
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('strips a javascript: href, including obfuscated forms', () => {
|
|
27
|
+
const vectors = [
|
|
28
|
+
'javascript:alert(1)',
|
|
29
|
+
'JaVaScRiPt:alert(1)',
|
|
30
|
+
' javascript:alert(1)',
|
|
31
|
+
'java\tscript:alert(1)',
|
|
32
|
+
'java\nscript:alert(1)',
|
|
33
|
+
'vbscript:msgbox(1)',
|
|
34
|
+
]
|
|
35
|
+
for (const href of vectors) {
|
|
36
|
+
const out = sanitizeHtml(`<a href="${href}">x</a>`)
|
|
37
|
+
expect(out.toLowerCase(), href).not.toContain('javascript:')
|
|
38
|
+
expect(out.toLowerCase(), href).not.toContain('vbscript:')
|
|
39
|
+
expect(out, href).not.toContain('href')
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('drops an img with an onerror payload but keeps a safe one', () => {
|
|
44
|
+
expect(sanitizeHtml('<img src=x onerror="alert(1)">')).not.toContain('onerror')
|
|
45
|
+
const safe = sanitizeHtml('<img src="/logo.png" alt="Logo">')
|
|
46
|
+
expect(safe).toContain('src="/logo.png"')
|
|
47
|
+
expect(safe).toContain('alt="Logo"')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('allows a base64 image data URL but not data:text/html', () => {
|
|
51
|
+
const png = 'data:image/png;base64,iVBORw0KGgo='
|
|
52
|
+
expect(sanitizeHtml(`<img src="${png}">`)).toContain('src=')
|
|
53
|
+
expect(sanitizeHtml('<img src="data:text/html;base64,PHNjcmlwdD4=">')).not.toContain('src=')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('removes style attributes and style/iframe subtrees', () => {
|
|
57
|
+
expect(sanitizeHtml('<p style="position:fixed;top:0">x</p>')).not.toContain('style')
|
|
58
|
+
expect(sanitizeHtml('<style>body{display:none}</style><p>x</p>')).not.toContain('display')
|
|
59
|
+
expect(sanitizeHtml('<iframe src="https://evil.test"></iframe>')).not.toContain('iframe')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('unwraps unknown tags but keeps their text', () => {
|
|
63
|
+
const out = sanitizeHtml('<marquee>keep me</marquee>')
|
|
64
|
+
expect(out).toContain('keep me')
|
|
65
|
+
expect(out).not.toContain('marquee')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('adds rel=noopener to links that open a new tab', () => {
|
|
69
|
+
const out = sanitizeHtml('<a href="https://x.test" target="_blank">x</a>')
|
|
70
|
+
expect(out).toContain('rel="noopener noreferrer"')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('sanitizeHtml - preserving legitimate content', () => {
|
|
75
|
+
it('keeps the formatting tags SvRichText produces', () => {
|
|
76
|
+
const html = '<p><strong>b</strong> <em>i</em> <u>u</u> <s>s</s> <code>c</code></p><ul><li>one</li></ul>'
|
|
77
|
+
expect(sanitizeHtml(html)).toBe(html)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('is idempotent', () => {
|
|
81
|
+
const once = sanitizeHtml('<p onclick="x()">hi <b>there</b></p>')
|
|
82
|
+
expect(sanitizeHtml(once)).toBe(once)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('passes plain text through untouched and handles empty input', () => {
|
|
86
|
+
expect(sanitizeHtml('just text')).toBe('just text')
|
|
87
|
+
expect(sanitizeHtml('')).toBe('')
|
|
88
|
+
expect(sanitizeHtml(null)).toBe('')
|
|
89
|
+
expect(sanitizeHtml(undefined)).toBe('')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('renderMarkdown', () => {
|
|
94
|
+
it('renders headings, emphasis, code and lists', () => {
|
|
95
|
+
expect(renderMarkdown('# Title')).toBe('<h1>Title</h1>')
|
|
96
|
+
expect(renderMarkdown('**bold**')).toContain('<strong>bold</strong>')
|
|
97
|
+
expect(renderMarkdown('*it*')).toContain('<em>it</em>')
|
|
98
|
+
expect(renderMarkdown('~~gone~~')).toContain('<del>gone</del>')
|
|
99
|
+
expect(renderMarkdown('`code`')).toContain('<code>code</code>')
|
|
100
|
+
expect(renderMarkdown('- a\n- b')).toBe('<ul><li>a</li><li>b</li></ul>')
|
|
101
|
+
expect(renderMarkdown('1. a\n2. b')).toBe('<ol><li>a</li><li>b</li></ol>')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('does not let emphasis leak into inline code', () => {
|
|
105
|
+
expect(renderMarkdown('`**not bold**`')).toContain('<code>**not bold**</code>')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('escapes raw HTML in the source instead of passing it through', () => {
|
|
109
|
+
const out = renderMarkdown('<script>alert(1)</script>')
|
|
110
|
+
expect(out).not.toContain('<script>')
|
|
111
|
+
expect(out).toContain('<script>')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('will not smuggle javascript: through a Markdown link', () => {
|
|
115
|
+
const out = renderMarkdown('[click](javascript:alert(1))')
|
|
116
|
+
expect(out).toContain('click')
|
|
117
|
+
expect(out).not.toContain('href')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('handles empty input', () => {
|
|
121
|
+
expect(renderMarkdown('')).toBe('')
|
|
122
|
+
expect(renderMarkdown(null)).toBe('')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('htmlToText', () => {
|
|
127
|
+
it('flattens markup to a single readable line', () => {
|
|
128
|
+
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one two')
|
|
129
|
+
expect(htmlToText('<ul><li>a</li><li>b</li></ul>')).toBe('a b')
|
|
130
|
+
expect(htmlToText('a<br>b')).toBe('a b')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('decodes entities and drops script content', () => {
|
|
134
|
+
expect(htmlToText('<p>a & b</p>')).toBe('a & b')
|
|
135
|
+
expect(htmlToText('<script>alert(1)</script><p>safe</p>')).toBe('safe')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('handles empty input', () => {
|
|
139
|
+
expect(htmlToText('')).toBe('')
|
|
140
|
+
expect(htmlToText(null)).toBe('')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('sanitizeHtml - SSR path (no DOMParser)', () => {
|
|
145
|
+
// During SSR there is no DOM, so a second, regex-based implementation runs.
|
|
146
|
+
// It is stricter than the DOM path, and it must not be the weak link: the
|
|
147
|
+
// server-rendered HTML reaches the browser before hydration re-sanitizes.
|
|
148
|
+
const withoutDom = (html: string): string => {
|
|
149
|
+
const real = globalThis.DOMParser
|
|
150
|
+
// @ts-expect-error - deleting a global for the duration of the assertion
|
|
151
|
+
delete globalThis.DOMParser
|
|
152
|
+
try {
|
|
153
|
+
return sanitizeHtml(html)
|
|
154
|
+
} finally {
|
|
155
|
+
globalThis.DOMParser = real
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it('drops scripts, styles and iframes with their contents', () => {
|
|
160
|
+
expect(withoutDom('<p>hi</p><script>alert(1)</script>')).toBe('<p>hi</p>')
|
|
161
|
+
expect(withoutDom('<style>body{x:y}</style><p>hi</p>')).toBe('<p>hi</p>')
|
|
162
|
+
expect(withoutDom('<iframe src="https://evil.test"></iframe>ok')).toBe('ok')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('strips every attribute, so no handler or URL survives', () => {
|
|
166
|
+
expect(withoutDom('<p onclick="alert(1)">x</p>')).toBe('<p>x</p>')
|
|
167
|
+
expect(withoutDom('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>')
|
|
168
|
+
// The tag itself may stay, but with no src and no handler it is inert.
|
|
169
|
+
const img = withoutDom('<img src=x onerror="alert(1)">')
|
|
170
|
+
expect(img).toBe('<img>')
|
|
171
|
+
expect(img).not.toContain('onerror')
|
|
172
|
+
expect(img).not.toContain('src')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('keeps plain allowlisted formatting', () => {
|
|
176
|
+
expect(withoutDom('<p><strong>b</strong> <em>i</em></p>')).toBe('<p><strong>b</strong> <em>i</em></p>')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('removes unknown tags entirely', () => {
|
|
180
|
+
expect(withoutDom('<marquee>text</marquee>')).toBe('text')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sanitize-html - a dependency-free HTML sanitizer and a small Markdown
|
|
3
|
+
* renderer, sized for what a grid cell needs to display.
|
|
4
|
+
*
|
|
5
|
+
* A rich-text cell has to paint markup that came from a user, which is the one
|
|
6
|
+
* place a data grid can hand an attacker script execution. Everything rendered
|
|
7
|
+
* through `SvRichCell` goes through `sanitizeHtml` first, and the allowlist is
|
|
8
|
+
* deliberately narrower than a general-purpose sanitizer: it covers the tags
|
|
9
|
+
* `SvRichText` can produce, plus links and images.
|
|
10
|
+
*
|
|
11
|
+
* The parse uses the platform parser where there is one (`DOMParser`, which
|
|
12
|
+
* neither executes scripts nor runs loaders on a detached document) and a
|
|
13
|
+
* conservative regex stripper during SSR, where there is no DOM. The SSR path
|
|
14
|
+
* is stricter by design: when in doubt it drops markup rather than passing it
|
|
15
|
+
* through, and the hydrated client re-sanitizes with the real parser anyway.
|
|
16
|
+
*
|
|
17
|
+
* This is not a general-purpose sanitizer for arbitrary third-party HTML. It is
|
|
18
|
+
* a narrow allowlist for cell content. To render something outside the
|
|
19
|
+
* allowlist, extend it explicitly rather than widening the defaults.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Tags that survive sanitization. Everything else is unwrapped or dropped. */
|
|
23
|
+
const ALLOWED_TAGS = new Set([
|
|
24
|
+
'a', 'b', 'blockquote', 'br', 'code', 'del', 'div', 'em', 'h1', 'h2', 'h3',
|
|
25
|
+
'h4', 'h5', 'h6', 'i', 'img', 'li', 'mark', 'ol', 'p', 'pre', 's', 'small',
|
|
26
|
+
'span', 'strong', 'sub', 'sup', 'u', 'ul',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
/** Tags whose whole subtree is removed rather than unwrapped. What sits inside
|
|
30
|
+
* a <script> or <style> is code, so keeping its text would be worse than
|
|
31
|
+
* losing it. */
|
|
32
|
+
const DROP_SUBTREE = new Set([
|
|
33
|
+
'script', 'style', 'iframe', 'object', 'embed', 'noscript', 'template',
|
|
34
|
+
'svg', 'math', 'form', 'input', 'button', 'textarea', 'select', 'link',
|
|
35
|
+
'meta', 'base', 'title', 'head',
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
/** Attributes allowed per tag. `*` applies to every allowed tag. A `style`
|
|
39
|
+
* attribute is never allowed: it is the classic sanitizer hole (url(...)
|
|
40
|
+
* payloads, position tricks that let a cell cover the page chrome), and cell
|
|
41
|
+
* styling belongs to the column rather than to the data. */
|
|
42
|
+
const ALLOWED_ATTRS: Record<string, Set<string>> = {
|
|
43
|
+
'*': new Set(['class', 'dir', 'lang', 'title']),
|
|
44
|
+
a: new Set(['href', 'target', 'rel']),
|
|
45
|
+
img: new Set(['src', 'alt', 'width', 'height']),
|
|
46
|
+
ol: new Set(['start']),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** URL schemes a href/src may use. Anything else, `javascript:` and
|
|
50
|
+
* `vbscript:` included, is dropped. `data:` is allowed only on an image src
|
|
51
|
+
* and only for image media types, because `data:text/html` is a script
|
|
52
|
+
* vector. */
|
|
53
|
+
const SAFE_URL = /^(?:https?:|mailto:|tel:|\/|\.\/|\.\.\/|#)/i
|
|
54
|
+
const SAFE_IMG_DATA_URL = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[a-z0-9+/=]*$/i
|
|
55
|
+
|
|
56
|
+
function isSafeUrl(value: string, tag: string, attr: string): boolean {
|
|
57
|
+
// Strip control characters and whitespace first: "java\tscript:" and
|
|
58
|
+
// "java\nscript:" are both read as javascript: by browsers.
|
|
59
|
+
// Matching control characters is the whole point here, hence the disable.
|
|
60
|
+
// eslint-disable-next-line no-control-regex
|
|
61
|
+
const url = value.replace(/[\u0000-\u0020\u007f]/g, '')
|
|
62
|
+
if (tag === 'img' && attr === 'src' && SAFE_IMG_DATA_URL.test(url)) return true
|
|
63
|
+
return SAFE_URL.test(url)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function attrAllowed(tag: string, attr: string): boolean {
|
|
67
|
+
if (attr.startsWith('on')) return false // every event handler, in one rule
|
|
68
|
+
if (ALLOWED_ATTRS['*']!.has(attr)) return true
|
|
69
|
+
return ALLOWED_ATTRS[tag]?.has(attr) ?? false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** DOM path: walk the parsed tree and strip anything off the allowlist. */
|
|
73
|
+
function sanitizeWithDom(html: string): string {
|
|
74
|
+
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
|
75
|
+
const walk = (node: Element): void => {
|
|
76
|
+
// Snapshot the child list: the loop mutates it as it unwraps and removes.
|
|
77
|
+
for (const child of [...node.children]) {
|
|
78
|
+
const tag = child.tagName.toLowerCase()
|
|
79
|
+
if (DROP_SUBTREE.has(tag)) {
|
|
80
|
+
child.remove()
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (!ALLOWED_TAGS.has(tag)) {
|
|
84
|
+
// Unwrap rather than drop, so an unknown wrapper does not cost the user
|
|
85
|
+
// the text inside it.
|
|
86
|
+
walk(child)
|
|
87
|
+
child.replaceWith(...child.childNodes)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
for (const attr of [...child.attributes]) {
|
|
91
|
+
const name = attr.name.toLowerCase()
|
|
92
|
+
if (!attrAllowed(tag, name)) {
|
|
93
|
+
child.removeAttribute(attr.name)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if ((name === 'href' || name === 'src') && !isSafeUrl(attr.value, tag, name)) {
|
|
97
|
+
child.removeAttribute(attr.name)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// A link that opens a new tab gets rel="noopener", so the opened page
|
|
101
|
+
// cannot reach back through window.opener.
|
|
102
|
+
if (tag === 'a' && child.getAttribute('target')) {
|
|
103
|
+
child.setAttribute('rel', 'noopener noreferrer')
|
|
104
|
+
}
|
|
105
|
+
walk(child)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
walk(doc.body)
|
|
109
|
+
return doc.body.innerHTML
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* SSR path: no DOM, so drop dangerous subtrees outright and keep only
|
|
114
|
+
* allowlisted tags in their attribute-free form. Stricter than the DOM path on
|
|
115
|
+
* purpose - the client re-sanitizes on hydration, so the cost is a moment of
|
|
116
|
+
* plainer markup rather than a hole.
|
|
117
|
+
*/
|
|
118
|
+
function sanitizeWithoutDom(html: string): string {
|
|
119
|
+
let out = html
|
|
120
|
+
for (const tag of DROP_SUBTREE) {
|
|
121
|
+
out = out.replace(new RegExp(`<${tag}\\b[\\s\\S]*?</${tag}\\s*>`, 'gi'), '')
|
|
122
|
+
out = out.replace(new RegExp(`<${tag}\\b[^>]*/?>`, 'gi'), '')
|
|
123
|
+
}
|
|
124
|
+
return out.replace(/<\/?([a-z0-9-]+)([^>]*)>/gi, (match, rawTag: string, attrs: string) => {
|
|
125
|
+
const tag = rawTag.toLowerCase()
|
|
126
|
+
if (!ALLOWED_TAGS.has(tag)) return ''
|
|
127
|
+
if (match.startsWith('</')) return `</${tag}>`
|
|
128
|
+
return attrs.trim() ? `<${tag}>` : match
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Return `html` with everything outside the allowlist removed. Idempotent, so
|
|
134
|
+
* it is safe to call on already-sanitized markup.
|
|
135
|
+
*/
|
|
136
|
+
export function sanitizeHtml(html: string | null | undefined): string {
|
|
137
|
+
const input = String(html ?? '')
|
|
138
|
+
if (!input) return ''
|
|
139
|
+
if (!input.includes('<')) return input // no angle bracket, no markup to strip
|
|
140
|
+
return typeof DOMParser === 'undefined' ? sanitizeWithoutDom(input) : sanitizeWithDom(input)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Escape text so it embeds in HTML as literal characters. */
|
|
144
|
+
function escapeHtml(text: string): string {
|
|
145
|
+
return text
|
|
146
|
+
.replace(/&/g, '&')
|
|
147
|
+
.replace(/</g, '<')
|
|
148
|
+
.replace(/>/g, '>')
|
|
149
|
+
.replace(/"/g, '"')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A sentinel that cannot occur in escaped input, used to park inline-code
|
|
153
|
+
* spans while the emphasis and link passes run over the rest. */
|
|
154
|
+
const MARK = '\u0000'
|
|
155
|
+
|
|
156
|
+
/** Inline Markdown. Code is extracted first so backticks protect their
|
|
157
|
+
* contents from the emphasis and link passes. */
|
|
158
|
+
function renderInline(text: string): string {
|
|
159
|
+
const code: string[] = []
|
|
160
|
+
let out = escapeHtml(text).replace(/`([^`]+)`/g, (_m, c: string) => {
|
|
161
|
+
code.push(c)
|
|
162
|
+
return `${MARK}${code.length - 1}${MARK}`
|
|
163
|
+
})
|
|
164
|
+
out = out
|
|
165
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<img src="$2" alt="$1">')
|
|
166
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>')
|
|
167
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
168
|
+
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
|
169
|
+
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
|
170
|
+
return out.replace(new RegExp(`${MARK}(\\d+)${MARK}`, 'g'), (_m, i: string) => `<code>${code[Number(i)]}</code>`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Render a common Markdown subset to HTML: headings, bold, italic,
|
|
175
|
+
* strikethrough, inline code, links, images, and bullet / ordered lists.
|
|
176
|
+
*
|
|
177
|
+
* Deliberately not a full CommonMark implementation - a grid cell shows a line
|
|
178
|
+
* or two, and a complete parser is a dependency this package does not take. The
|
|
179
|
+
* output is sanitized, so a Markdown link cannot smuggle `javascript:`.
|
|
180
|
+
*/
|
|
181
|
+
export function renderMarkdown(markdown: string | null | undefined): string {
|
|
182
|
+
const src = String(markdown ?? '')
|
|
183
|
+
if (!src) return ''
|
|
184
|
+
const out: string[] = []
|
|
185
|
+
let list: 'ul' | 'ol' | null = null
|
|
186
|
+
const closeList = () => {
|
|
187
|
+
if (list) out.push(`</${list}>`)
|
|
188
|
+
list = null
|
|
189
|
+
}
|
|
190
|
+
for (const line of src.split(/\r?\n/)) {
|
|
191
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/)
|
|
192
|
+
const bullet = line.match(/^\s*[-*+]\s+(.*)$/)
|
|
193
|
+
const ordered = line.match(/^\s*\d+[.)]\s+(.*)$/)
|
|
194
|
+
if (heading) {
|
|
195
|
+
closeList()
|
|
196
|
+
const level = heading[1]!.length
|
|
197
|
+
out.push(`<h${level}>${renderInline(heading[2]!)}</h${level}>`)
|
|
198
|
+
} else if (bullet || ordered) {
|
|
199
|
+
const want = bullet ? 'ul' : 'ol'
|
|
200
|
+
if (list !== want) {
|
|
201
|
+
closeList()
|
|
202
|
+
out.push(`<${want}>`)
|
|
203
|
+
list = want
|
|
204
|
+
}
|
|
205
|
+
out.push(`<li>${renderInline((bullet ?? ordered)![1]!)}</li>`)
|
|
206
|
+
} else if (!line.trim()) {
|
|
207
|
+
closeList()
|
|
208
|
+
} else {
|
|
209
|
+
closeList()
|
|
210
|
+
out.push(`<p>${renderInline(line)}</p>`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
closeList()
|
|
214
|
+
return sanitizeHtml(out.join(''))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Strip markup down to readable text, for exports, aria labels and tooltips
|
|
218
|
+
* where a cell's HTML has to collapse to one plain string. */
|
|
219
|
+
export function htmlToText(html: string | null | undefined): string {
|
|
220
|
+
const clean = sanitizeHtml(html)
|
|
221
|
+
if (!clean) return ''
|
|
222
|
+
const spaced = clean
|
|
223
|
+
.replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, ' ')
|
|
224
|
+
.replace(/<br\s*\/?>/gi, ' ')
|
|
225
|
+
return spaced
|
|
226
|
+
.replace(/<[^>]*>/g, '')
|
|
227
|
+
.replace(/ /gi, ' ')
|
|
228
|
+
.replace(/</gi, '<')
|
|
229
|
+
.replace(/>/gi, '>')
|
|
230
|
+
.replace(/"/gi, '"')
|
|
231
|
+
.replace(/'/gi, "'")
|
|
232
|
+
.replace(/&/gi, '&')
|
|
233
|
+
.replace(/\s+/g, ' ')
|
|
234
|
+
.trim()
|
|
235
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selection-bar-view registry - the injection seam for the bulk-action bar that
|
|
3
|
+
* floats over the grid while rows are selected. `<SvGrid selectionBar={...}>`
|
|
4
|
+
* and its config types ship in the free grid, but the BAR itself is a paid
|
|
5
|
+
* feature in `@svgrid/enterprise`. This tiny reactive holder lets enterprise
|
|
6
|
+
* register the renderer at import time; the grid looks it up and mounts it, or
|
|
7
|
+
* shows an upsell placeholder when it is absent.
|
|
8
|
+
*
|
|
9
|
+
* Same shape as `scheduler-view` and `board-view`, deliberately: three seams
|
|
10
|
+
* that behave differently would be three things to learn.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // in @svgrid/enterprise, at module load:
|
|
14
|
+
* import { registerSelectionBarView } from '@svgrid/grid'
|
|
15
|
+
* import SvGridSelectionBar from './SvGridSelectionBar.svelte'
|
|
16
|
+
* registerSelectionBarView(SvGridSelectionBar)
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import type { Component } from 'svelte'
|
|
20
|
+
|
|
21
|
+
let renderer = $state<Component<any> | null>(null)
|
|
22
|
+
|
|
23
|
+
/** Register the component that renders the selection bar. Enterprise calls this. */
|
|
24
|
+
export function registerSelectionBarView(component: Component<any>): void {
|
|
25
|
+
renderer = component
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The registered selection-bar renderer, or null when enterprise is not installed. */
|
|
29
|
+
export function getSelectionBarView(): Component<any> | null {
|
|
30
|
+
return renderer
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a selection-bar renderer has been registered. */
|
|
34
|
+
export function hasSelectionBarView(): boolean {
|
|
35
|
+
return renderer != null
|
|
36
|
+
}
|
package/src/selection.test.ts
CHANGED
|
@@ -44,6 +44,9 @@ function makeCtx(overrides: any = {}) {
|
|
|
44
44
|
selectionRange: { anchor: null, focus: null },
|
|
45
45
|
isDraggingSelection: false,
|
|
46
46
|
fillDrag: null,
|
|
47
|
+
moveDrag: null,
|
|
48
|
+
moveGrabHover: false,
|
|
49
|
+
moveCellsEffective: false,
|
|
47
50
|
activeAtPointerDown: null,
|
|
48
51
|
editingEnabled: false,
|
|
49
52
|
editingCell: undefined,
|
|
@@ -61,6 +64,17 @@ function makeCtx(overrides: any = {}) {
|
|
|
61
64
|
onCellDoubleClick: vi.fn(),
|
|
62
65
|
onFillPointerUp: vi.fn(),
|
|
63
66
|
onFillPointerMove: vi.fn(),
|
|
67
|
+
// Range drag-and-drop. `startMoveDrag` returning false is the "the pointer
|
|
68
|
+
// was not on the border" answer, which is what every test below assumes -
|
|
69
|
+
// override it to true to exercise the grab path.
|
|
70
|
+
startMoveDrag: vi.fn(() => false),
|
|
71
|
+
onMovePointerUp: vi.fn(),
|
|
72
|
+
onMovePointerMove: vi.fn(),
|
|
73
|
+
isOnMoveGrabStrip: vi.fn(() => false),
|
|
74
|
+
// Edge auto-scroll: one rAF loop in the clipboard module, driven from here
|
|
75
|
+
// for plain drag-select. Its own behaviour is covered in move-cells.test.ts.
|
|
76
|
+
trackEdgeScroll: vi.fn(),
|
|
77
|
+
stopEdgeScroll: vi.fn(),
|
|
64
78
|
grid: {
|
|
65
79
|
setRowSelection,
|
|
66
80
|
setActiveCell,
|
|
@@ -428,6 +442,149 @@ describe('createSelection - endDragSelection / window move', () => {
|
|
|
428
442
|
sel.onWindowPointerMove({ buttons: 1 } as any)
|
|
429
443
|
expect(ctx.isDraggingSelection).toBe(true)
|
|
430
444
|
})
|
|
445
|
+
|
|
446
|
+
it('endDragSelection cancels the edge-scroll loop', () => {
|
|
447
|
+
// The loop exits on its own once no drag is live, but cancelling the
|
|
448
|
+
// pending frame here stops one more from firing after the gesture ended.
|
|
449
|
+
const ctx = makeCtx({ isDraggingSelection: true })
|
|
450
|
+
const sel = createSelection(ctx)
|
|
451
|
+
sel.endDragSelection()
|
|
452
|
+
expect(ctx.stopEdgeScroll).toHaveBeenCalled()
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('feeds the edge-scroll loop while drag-selecting', () => {
|
|
456
|
+
// Drag-select had the same ceiling as the other two drags: a range could
|
|
457
|
+
// only ever cover cells that were on screen when it started.
|
|
458
|
+
const ctx = makeCtx({ isDraggingSelection: true })
|
|
459
|
+
const sel = createSelection(ctx)
|
|
460
|
+
const ev = { buttons: 1, clientX: 10, clientY: 10 } as any
|
|
461
|
+
sel.onWindowPointerMove(ev)
|
|
462
|
+
expect(ctx.trackEdgeScroll).toHaveBeenCalledWith(ev)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('does not feed the loop once the button is released', () => {
|
|
466
|
+
const ctx = makeCtx({ isDraggingSelection: true })
|
|
467
|
+
const sel = createSelection(ctx)
|
|
468
|
+
sel.onWindowPointerMove({ buttons: 0 } as any)
|
|
469
|
+
expect(ctx.trackEdgeScroll).not.toHaveBeenCalled()
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('endDragSelection commits a pending range move', () => {
|
|
473
|
+
const ctx = makeCtx({ moveDrag: { x: 1 } })
|
|
474
|
+
const sel = createSelection(ctx)
|
|
475
|
+
sel.endDragSelection()
|
|
476
|
+
expect(ctx.onMovePointerUp).toHaveBeenCalled()
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('onWindowPointerMove delegates to the range move when one is in flight', () => {
|
|
480
|
+
// A move outranks a fill: they can never both be active, and checking it
|
|
481
|
+
// first keeps the fill path from swallowing the event.
|
|
482
|
+
const ctx = makeCtx({ moveDrag: { x: 1 }, fillDrag: { x: 1 } })
|
|
483
|
+
const sel = createSelection(ctx)
|
|
484
|
+
const ev = { buttons: 1 } as any
|
|
485
|
+
sel.onWindowPointerMove(ev)
|
|
486
|
+
expect(ctx.onMovePointerMove).toHaveBeenCalledWith(ev)
|
|
487
|
+
expect(ctx.onFillPointerMove).not.toHaveBeenCalled()
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
describe('createSelection - range move grab', () => {
|
|
492
|
+
/** A pointer event over a td that reports itself from `closest()`. */
|
|
493
|
+
function overCell() {
|
|
494
|
+
const cell: any = { dataset: { svgridRow: '1', svgridCol: '1' } }
|
|
495
|
+
cell.closest = () => cell
|
|
496
|
+
return { button: 0, clientX: 10, clientY: 10, target: cell, pointerType: 'mouse' } as any
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
it('hands the pointerdown to the move grab before touching the selection', () => {
|
|
500
|
+
const ctx = makeCtx({
|
|
501
|
+
allRows: [{ id: 'r0' }, { id: 'r1' }],
|
|
502
|
+
allColumns: [{ id: 'a' }, { id: 'b' }],
|
|
503
|
+
startMoveDrag: vi.fn(() => true),
|
|
504
|
+
})
|
|
505
|
+
const sel = createSelection(ctx)
|
|
506
|
+
sel.onCellPointerDown(1, 1, overCell())
|
|
507
|
+
expect(ctx.startMoveDrag).toHaveBeenCalled()
|
|
508
|
+
// The selection the user grabbed is still intact, and no drag-select began.
|
|
509
|
+
expect(ctx.selectionRange).toEqual({ anchor: null, focus: null })
|
|
510
|
+
expect(ctx.isDraggingSelection).toBe(false)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('swallows the click that follows a grab, so the range survives', () => {
|
|
514
|
+
const onCellClick = vi.fn()
|
|
515
|
+
const ctx = makeCtx({
|
|
516
|
+
allRows: [makeDataRow('r0', { id: 0 }), makeDataRow('r1', { id: 1 })],
|
|
517
|
+
allColumns: [makeColumn('c0', { editorType: 'text' }), makeColumn('c1', { editorType: 'text' })],
|
|
518
|
+
gridRootEl: { focus: vi.fn() },
|
|
519
|
+
props: { onCellClick },
|
|
520
|
+
startMoveDrag: vi.fn(() => true),
|
|
521
|
+
})
|
|
522
|
+
const sel = createSelection(ctx)
|
|
523
|
+
sel.onCellPointerDown(1, 1, overCell())
|
|
524
|
+
sel.onCellClick(1, 1)
|
|
525
|
+
expect(onCellClick).not.toHaveBeenCalled()
|
|
526
|
+
// Only the one click is swallowed - the next behaves normally.
|
|
527
|
+
sel.onCellClick(1, 1)
|
|
528
|
+
expect(onCellClick).toHaveBeenCalledTimes(1)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('falls through to normal selection when the pointer misses the border', () => {
|
|
532
|
+
const ctx = makeCtx({
|
|
533
|
+
allRows: [{ id: 'r0' }, { id: 'r1' }],
|
|
534
|
+
allColumns: [{ id: 'a' }, { id: 'b' }],
|
|
535
|
+
})
|
|
536
|
+
const sel = createSelection(ctx)
|
|
537
|
+
sel.onCellPointerDown(1, 1, overCell())
|
|
538
|
+
expect(ctx.selectionRange.anchor).toEqual({ rowIndex: 1, colIndex: 1 })
|
|
539
|
+
expect(ctx.isDraggingSelection).toBe(true)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('updateMoveGrabHover leaves the flag alone while moveCells is off', () => {
|
|
543
|
+
const ctx = makeCtx({ moveCellsEffective: false })
|
|
544
|
+
const sel = createSelection(ctx)
|
|
545
|
+
sel.onWindowPointerMove(overCell())
|
|
546
|
+
expect(ctx.isOnMoveGrabStrip).not.toHaveBeenCalled()
|
|
547
|
+
expect(ctx.moveGrabHover).toBe(false)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('raises the hover flag on the border and drops it off it', () => {
|
|
551
|
+
const ctx = makeCtx({
|
|
552
|
+
moveCellsEffective: true,
|
|
553
|
+
// Without an anchored range the hover check bails before touching the
|
|
554
|
+
// DOM - correctly, since there is then no border to grab.
|
|
555
|
+
selectionRange: { anchor: { rowIndex: 1, colIndex: 1 }, focus: { rowIndex: 2, colIndex: 2 } },
|
|
556
|
+
isOnMoveGrabStrip: vi.fn(() => true),
|
|
557
|
+
})
|
|
558
|
+
const sel = createSelection(ctx)
|
|
559
|
+
sel.onWindowPointerMove(overCell())
|
|
560
|
+
expect(ctx.moveGrabHover).toBe(true)
|
|
561
|
+
ctx.isOnMoveGrabStrip = vi.fn(() => false)
|
|
562
|
+
sel.onWindowPointerMove(overCell())
|
|
563
|
+
expect(ctx.moveGrabHover).toBe(false)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('never walks the DOM while nothing is selected', () => {
|
|
567
|
+
// This runs on every window pointermove, so the guard order matters:
|
|
568
|
+
// two property reads must come before any closest() call.
|
|
569
|
+
const closest = vi.fn(() => null)
|
|
570
|
+
const ctx = makeCtx({ moveCellsEffective: true, isOnMoveGrabStrip: vi.fn(() => true) })
|
|
571
|
+
const sel = createSelection(ctx)
|
|
572
|
+
sel.onWindowPointerMove({ target: { closest }, buttons: 0 } as any)
|
|
573
|
+
expect(closest).not.toHaveBeenCalled()
|
|
574
|
+
expect(ctx.isOnMoveGrabStrip).not.toHaveBeenCalled()
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('drops the hover flag when the pointer leaves the grid entirely', () => {
|
|
578
|
+
const ctx = makeCtx({
|
|
579
|
+
moveCellsEffective: true,
|
|
580
|
+
moveGrabHover: true,
|
|
581
|
+
selectionRange: { anchor: { rowIndex: 1, colIndex: 1 }, focus: { rowIndex: 2, colIndex: 2 } },
|
|
582
|
+
isOnMoveGrabStrip: vi.fn(() => true),
|
|
583
|
+
})
|
|
584
|
+
const sel = createSelection(ctx)
|
|
585
|
+
sel.onWindowPointerMove({ target: { closest: () => null }, buttons: 0 } as any)
|
|
586
|
+
expect(ctx.moveGrabHover).toBe(false)
|
|
587
|
+
})
|
|
431
588
|
})
|
|
432
589
|
|
|
433
590
|
describe('createSelection - onCellClick', () => {
|