@asd20/ui-next 2.17.0 → 2.19.0

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ # [2.19.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.18.0...ui-next-v2.19.0) (2026-08-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * add azure blob mp4 and myvrspot video urls for tiptap editor ([b7bc2e9](https://github.com/academydistrict20/asd20-ui-next/commit/b7bc2e94bf8502bb990bfb063c14b6631f981f80))
9
+
10
+ # [2.18.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.17.0...ui-next-v2.18.0) (2026-08-06)
11
+
12
+
13
+ ### Features
14
+
15
+ * add tiptap gallery support ([7371f56](https://github.com/academydistrict20/asd20-ui-next/commit/7371f561c6c0a3e6a2275a9537beba72c86e6837))
16
+
3
17
  # [2.17.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.16.0...ui-next-v2.17.0) (2026-08-05)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.17.0",
3
+ "version": "2.19.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -158,6 +158,37 @@ export default {
158
158
  margin-bottom: 0;
159
159
  }
160
160
 
161
+ :deep(.asd20-message-image-gallery) {
162
+ box-sizing: border-box;
163
+ max-width: 100%;
164
+ margin: 1rem 0;
165
+ overflow: hidden;
166
+ }
167
+
168
+ :deep(.asd20-message-image-gallery__images) {
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ gap: 0.75rem;
173
+ width: 100%;
174
+ height: auto !important;
175
+ max-height: min(32rem, 70vh) !important;
176
+ overflow: hidden;
177
+ }
178
+
179
+ :deep(.asd20-message-image-gallery__images > img) {
180
+ box-sizing: border-box;
181
+ display: block;
182
+ flex: 0 1 auto;
183
+ width: auto !important;
184
+ min-width: 0;
185
+ max-width: none !important;
186
+ height: auto !important;
187
+ max-height: min(32rem, 70vh) !important;
188
+ margin: 0 !important;
189
+ object-fit: contain;
190
+ }
191
+
161
192
  :deep(.asd20-message-video) {
162
193
  max-width: 100%;
163
194
  margin: 0.75rem 0;
@@ -27,6 +27,7 @@ const STANDARD_BLOCK_NODE_TYPES = new Set([
27
27
  'bulletList',
28
28
  'orderedList',
29
29
  'image',
30
+ 'imageGallery',
30
31
  'videoEmbed',
31
32
  'table',
32
33
  ])
@@ -58,6 +59,10 @@ const MARK_TYPES = new Set([
58
59
  const TABLE_HEADER_SCOPES = new Set(['col', 'row', 'colgroup', 'rowgroup'])
59
60
  const VIDEO_TITLE_MAX_LENGTH = 200
60
61
  const VIDEO_DESCRIPTION_MAX_LENGTH = 1000
62
+ const MYVRSPOT_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{16,256}$/
63
+ const MYVRSPOT_HOST = 'live.myvrspot.com'
64
+ const ASD20_VIDEO_HOST = 'asd20websitestorage.blob.core.windows.net'
65
+ const ASD20_VIDEO_PATH_PREFIX = '/asd20-images/videos/'
61
66
  const VIDEO_PROVIDER_RULES = Object.freeze([
62
67
  {
63
68
  host: 'www.youtube.com',
@@ -94,6 +99,7 @@ const NODE_ATTRIBUTE_NAMES = Object.freeze({
94
99
  orderedList: ['start', 'type'],
95
100
  listItem: [],
96
101
  image: ['src', 'alt', 'title', 'width', 'height'],
102
+ imageGallery: ['images'],
97
103
  videoEmbed: ['src', 'title', 'descriptiveText'],
98
104
  table: ['caption'],
99
105
  tableRow: [],
@@ -212,6 +218,18 @@ function isSafeResource(value, allowHash = false) {
212
218
  }
213
219
  }
214
220
 
221
+ function isSafeImageResource(value) {
222
+ if (typeof value !== 'string' || !value.trim()) return false
223
+ if (value.trim().startsWith('#')) return false
224
+
225
+ try {
226
+ const url = new URL(value.trim(), 'https://ui-next.invalid')
227
+ return url.protocol === 'http:' || url.protocol === 'https:'
228
+ } catch (_error) {
229
+ return false
230
+ }
231
+ }
232
+
215
233
  function isCanonicalVideoEmbedSource(value) {
216
234
  if (typeof value !== 'string' || !value) return false
217
235
 
@@ -222,14 +240,36 @@ function isCanonicalVideoEmbedSource(value) {
222
240
  url.username ||
223
241
  url.password ||
224
242
  url.port ||
225
- url.search ||
226
243
  url.hash
227
244
  ) {
228
245
  return false
229
246
  }
230
247
 
248
+ const host = url.hostname.toLowerCase()
249
+ if (host === MYVRSPOT_HOST) {
250
+ const videoId = url.searchParams.get('v') || ''
251
+ return (
252
+ url.pathname === '/iframe' &&
253
+ MYVRSPOT_VIDEO_ID_PATTERN.test(videoId) &&
254
+ url.searchParams.get('share') === '1' &&
255
+ [...url.searchParams].length === 2 &&
256
+ value === `https://${MYVRSPOT_HOST}/iframe?v=${videoId}&share=1`
257
+ )
258
+ }
259
+
260
+ if (host === ASD20_VIDEO_HOST) {
261
+ return (
262
+ !url.search &&
263
+ url.pathname.startsWith(ASD20_VIDEO_PATH_PREFIX) &&
264
+ url.pathname.toLowerCase().endsWith('.mp4') &&
265
+ value === `https://${ASD20_VIDEO_HOST}${url.pathname}`
266
+ )
267
+ }
268
+
269
+ if (url.search) return false
270
+
231
271
  const rule = VIDEO_PROVIDER_RULES.find(
232
- candidate => candidate.host === url.hostname.toLowerCase()
272
+ candidate => candidate.host === host
233
273
  )
234
274
  return Boolean(
235
275
  rule &&
@@ -328,6 +368,33 @@ function validateAtom(node, path) {
328
368
  return null
329
369
  }
330
370
 
371
+ function validateImageAttributes(node, path, requiredSource = false) {
372
+ const srcIssue = validateStringAttribute(
373
+ node,
374
+ 'src',
375
+ path,
376
+ requiredSource
377
+ )
378
+ if (srcIssue || (node.attrs?.src && !isSafeImageResource(node.attrs.src))) {
379
+ return srcIssue || issue('unsafe-resource', path, 'src')
380
+ }
381
+ for (const name of ['alt', 'title']) {
382
+ const attrIssue = validateStringAttribute(node, name, path)
383
+ if (attrIssue) return attrIssue
384
+ }
385
+ for (const name of ['width', 'height']) {
386
+ const value = node.attrs?.[name]
387
+ if (
388
+ value !== undefined &&
389
+ value !== null &&
390
+ (!Number.isFinite(value) || value <= 0)
391
+ ) {
392
+ return issue('malformed-attribute', path, name)
393
+ }
394
+ }
395
+ return null
396
+ }
397
+
331
398
  function validateNode(node, path) {
332
399
  if (!isRecord(node) || typeof node.type !== 'string') {
333
400
  return issue('malformed-node', path)
@@ -430,25 +497,27 @@ function validateNode(node, path) {
430
497
  : issue('invalid-child-node', `${path}.content[0]`, node.content[0].type)
431
498
  }
432
499
  if (node.type === 'image') {
433
- const srcIssue = validateStringAttribute(node, 'src', path)
434
- if (srcIssue || (node.attrs?.src && !isSafeResource(node.attrs.src))) {
435
- return srcIssue || issue('unsafe-resource', path, 'src')
436
- }
437
- for (const name of ['alt', 'title']) {
438
- const attrIssue = validateStringAttribute(node, name, path)
439
- if (attrIssue) return attrIssue
440
- }
441
- for (const name of ['width', 'height']) {
442
- const value = node.attrs?.[name]
443
- if (
444
- value !== undefined &&
445
- value !== null &&
446
- (!Number.isFinite(value) || value <= 0)
447
- ) {
448
- return issue('malformed-attribute', path, name)
500
+ return validateImageAttributes(node, path) || validateAtom(node, path)
501
+ }
502
+ if (node.type === 'imageGallery') {
503
+ const atomIssue = validateAtom(node, path)
504
+ if (atomIssue) return atomIssue
505
+ const images = node.attrs?.images
506
+ if (!Array.isArray(images) || images.length < 2 || images.length > 3) {
507
+ return issue('malformed-attribute', path, 'images')
508
+ }
509
+ for (let index = 0; index < images.length; index += 1) {
510
+ const imagePath = `${path}.attrs.images[${index}]`
511
+ if (!isRecord(images[index])) {
512
+ return issue('malformed-attribute', imagePath, 'image')
449
513
  }
514
+ const imageNode = { type: 'image', attrs: images[index] }
515
+ const attributeNameIssue = validateAttributeNames(imageNode, imagePath)
516
+ if (attributeNameIssue) return attributeNameIssue
517
+ const imageIssue = validateImageAttributes(imageNode, imagePath, true)
518
+ if (imageIssue) return imageIssue
450
519
  }
451
- return validateAtom(node, path)
520
+ return null
452
521
  }
453
522
  if (node.type === 'videoEmbed') {
454
523
  const srcIssue = validateStringAttribute(node, 'src', path, true)
@@ -692,6 +761,17 @@ function renderTable(node) {
692
761
  )}</tbody></table></div>`
693
762
  }
694
763
 
764
+ function renderImage(attrs) {
765
+ return `<img${attribute('src', attrs.src)}${attribute(
766
+ 'alt',
767
+ attrs.alt,
768
+ true
769
+ )}${attribute('title', attrs.title)}${attribute(
770
+ 'width',
771
+ attrs.width
772
+ )}${attribute('height', attrs.height)}>`
773
+ }
774
+
695
775
  function renderStandardNode(node) {
696
776
  if (node.type === 'text') return renderMarkedText(node)
697
777
  if (node.type === 'hardBreak') return '<br>'
@@ -721,14 +801,12 @@ function renderStandardNode(node) {
721
801
  }
722
802
  if (node.type === 'listItem') return `<li>${renderChildren(node)}</li>`
723
803
  if (node.type === 'image') {
724
- return `<img${attribute('src', node.attrs.src)}${attribute(
725
- 'alt',
726
- node.attrs.alt,
727
- true
728
- )}${attribute('title', node.attrs.title)}${attribute(
729
- 'width',
730
- node.attrs.width
731
- )}${attribute('height', node.attrs.height)}>`
804
+ return renderImage(node.attrs)
805
+ }
806
+ if (node.type === 'imageGallery') {
807
+ return `<figure class="asd20-message-image-gallery" data-cc-node="image-gallery"><div class="asd20-message-image-gallery__images" data-cc-gallery-images="">${node.attrs.images
808
+ .map(renderImage)
809
+ .join('')}</div></figure>`
732
810
  }
733
811
  if (node.type === 'videoEmbed') {
734
812
  return `<div class="asd20-message-video" data-cc-node="video"${attribute(