frappe-ui 1.0.0-beta.63 → 1.0.0-beta.64
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 +1 -1
- package/src/charts/areaChartOptions.test.ts +2 -3
- package/src/charts/barChartOptions.test.ts +8 -8
- package/src/charts/chartColors.test.ts +35 -15
- package/src/charts/comboChartOptions.test.ts +1 -1
- package/src/charts/donutChartOptions.test.ts +1 -1
- package/src/charts/heatmapOptions.test.ts +7 -13
- package/src/charts/lineChartOptions.test.ts +1 -1
- package/src/charts/style.css +8 -5
- package/src/charts/tokens.ts +51 -31
- package/src/components/shared/slotReactivity.cy.ts +36 -0
- package/src/components/shared/tabs/Pill.vue +2 -3
- package/src/molecules/editor/components/MediaNodeView.test.ts +30 -0
- package/src/molecules/editor/components/MediaNodeView.vue +13 -8
- package/src/molecules/editor/components/VideoControls.test.ts +118 -2
- package/src/molecules/editor/components/VideoControls.vue +89 -11
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@ import type { AxisChartConfig } from './types'
|
|
|
9
9
|
|
|
10
10
|
const tokens: ChartTokens = {
|
|
11
11
|
categorical: ['#111111', '#222222', '#333333'],
|
|
12
|
-
sequential: ['#000011', '#000022', '#000033'
|
|
12
|
+
sequential: ['#000011', '#000022', '#000033'],
|
|
13
13
|
diverging: ['#001100', '#002200', '#003300'],
|
|
14
14
|
axisLabel: 'ink-5',
|
|
15
15
|
axisTitle: 'ink-7',
|
|
@@ -169,9 +169,8 @@ describe('area chart option', () => {
|
|
|
169
169
|
build({
|
|
170
170
|
fillOpacity: 0.5,
|
|
171
171
|
series: [{ name: 'sales', fillOpacity: 0.25 }],
|
|
172
|
-
// A lone series takes the ramp's mid stop, hence the shifted blue.
|
|
173
172
|
}).series[0].areaStyle.color.colorStops[0].color,
|
|
174
|
-
).toBe('rgba(0, 0,
|
|
173
|
+
).toBe('rgba(0, 0, 17, 0.25)')
|
|
175
174
|
})
|
|
176
175
|
|
|
177
176
|
it('falls back to a flat fill for a color it cannot add alpha to', () => {
|
|
@@ -7,8 +7,8 @@ import type { AxisChartConfig, AxisChartSeriesConfig } from './types'
|
|
|
7
7
|
|
|
8
8
|
const tokens: ChartTokens = {
|
|
9
9
|
categorical: ['#111111', '#222222', '#333333'],
|
|
10
|
-
//
|
|
11
|
-
sequential: ['#000011', '#000022', '#000033'
|
|
10
|
+
// Three stops so even spacing across the ramp is visible.
|
|
11
|
+
sequential: ['#000011', '#000022', '#000033'],
|
|
12
12
|
diverging: ['#001100', '#002200', '#003300'],
|
|
13
13
|
axisLabel: 'ink-5',
|
|
14
14
|
axisTitle: 'ink-7',
|
|
@@ -70,13 +70,13 @@ describe('resolveSeriesColors', () => {
|
|
|
70
70
|
).toEqual({ sales: '#000011', refunds: 'red' })
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
-
it('gives a lone sequential series
|
|
73
|
+
it('gives a lone sequential series the darkest stop', () => {
|
|
74
74
|
expect(colorsFor({ series: [{ name: 'sales' }] })).toEqual({
|
|
75
|
-
sales: '#
|
|
75
|
+
sales: '#000011',
|
|
76
76
|
})
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
-
it('spaces sequential series out dark to light
|
|
79
|
+
it('spaces sequential series out dark to light', () => {
|
|
80
80
|
expect(Object.values(colorsFor({ series: named(3) }))).toEqual([
|
|
81
81
|
'#000011',
|
|
82
82
|
'#000022',
|
|
@@ -84,10 +84,10 @@ describe('resolveSeriesColors', () => {
|
|
|
84
84
|
])
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
-
it('cycles the sequential ramp once there are more series than
|
|
88
|
-
const colors = colorsFor({ series: named(
|
|
87
|
+
it('cycles the sequential ramp once there are more series than stops', () => {
|
|
88
|
+
const colors = colorsFor({ series: named(4) })
|
|
89
89
|
expect(colors.a).toBe('#000011')
|
|
90
|
-
expect(colors.
|
|
90
|
+
expect(colors.d).toBe(colors.a)
|
|
91
91
|
})
|
|
92
92
|
|
|
93
93
|
it('cycles the categorical ramp in series order', () => {
|
|
@@ -25,9 +25,6 @@ const tokens: ChartTokens = {
|
|
|
25
25
|
backdrop: '#ffffff',
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** The sequential ramp minus the two palest stops, as every chart reads it. */
|
|
29
|
-
const USABLE_SEQUENTIAL = tokens.sequential.slice(0, 7)
|
|
30
|
-
|
|
31
28
|
describe('chartColors: precedence', () => {
|
|
32
29
|
it("draws in the caller's own colors when they passed a list", () => {
|
|
33
30
|
expect(
|
|
@@ -56,7 +53,7 @@ describe('chartColors: precedence', () => {
|
|
|
56
53
|
).toEqual(tokens.categorical.slice(0, 2))
|
|
57
54
|
expect(
|
|
58
55
|
chartColors([], tokens, { fallback: 'sequential', count: 'ramp' }),
|
|
59
|
-
).toEqual(
|
|
56
|
+
).toEqual(tokens.sequential)
|
|
60
57
|
})
|
|
61
58
|
})
|
|
62
59
|
|
|
@@ -90,6 +87,38 @@ describe('chartColors: one color per thing drawn', () => {
|
|
|
90
87
|
})
|
|
91
88
|
})
|
|
92
89
|
|
|
90
|
+
describe('chartColors: how a sequential ramp is spent', () => {
|
|
91
|
+
const stops = (count: number) =>
|
|
92
|
+
chartColors('sequential', tokens, { fallback: 'sequential', count }).map(
|
|
93
|
+
(color) => tokens.sequential.indexOf(color) + 1,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
it('gives a lone series the deep end', () => {
|
|
97
|
+
expect(stops(1)).toEqual([1])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('keeps a small chart a prefix of the next size up', () => {
|
|
101
|
+
expect(stops(2)).toEqual([1, 4])
|
|
102
|
+
expect(stops(3)).toEqual([1, 4, 7])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('stays inside the first seven stops up to seven series', () => {
|
|
106
|
+
expect(stops(4)).toEqual([1, 3, 5, 7])
|
|
107
|
+
expect(stops(5)).toEqual([1, 3, 4, 6, 7])
|
|
108
|
+
expect(stops(6)).toEqual([1, 2, 3, 5, 6, 7])
|
|
109
|
+
expect(stops(7)).toEqual([1, 2, 3, 4, 5, 6, 7])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('reaches the pale stops only once the series outnumber the span', () => {
|
|
113
|
+
expect(stops(8)).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
|
|
114
|
+
expect(stops(9)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('cycles once there are more series than stops', () => {
|
|
118
|
+
expect(stops(10)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 1])
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
93
122
|
describe('chartColors: the ramp itself', () => {
|
|
94
123
|
it('gives the stops rather than a slot each', () => {
|
|
95
124
|
expect(
|
|
@@ -97,16 +126,7 @@ describe('chartColors: the ramp itself', () => {
|
|
|
97
126
|
fallback: 'sequential',
|
|
98
127
|
count: 'ramp',
|
|
99
128
|
}),
|
|
100
|
-
).toEqual(
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it('trims the sequential stops that vanish against a card', () => {
|
|
104
|
-
const ramp = chartColors(undefined, tokens, {
|
|
105
|
-
fallback: 'sequential',
|
|
106
|
-
count: 'ramp',
|
|
107
|
-
})
|
|
108
|
-
expect(ramp).not.toContain('#f5f5f5')
|
|
109
|
-
expect(ramp).not.toContain('#e0e0e0')
|
|
129
|
+
).toEqual(tokens.sequential)
|
|
110
130
|
})
|
|
111
131
|
|
|
112
132
|
it('takes a diverging ramp end to end', () => {
|
|
@@ -151,7 +171,7 @@ describe('chartColors: which end of the ramp leads', () => {
|
|
|
151
171
|
count: 'ramp',
|
|
152
172
|
deepEnd: 'last',
|
|
153
173
|
}),
|
|
154
|
-
).toEqual([...
|
|
174
|
+
).toEqual([...tokens.sequential].reverse())
|
|
155
175
|
})
|
|
156
176
|
|
|
157
177
|
it('leaves a categorical set alone, having no order to reverse', () => {
|
|
@@ -9,7 +9,7 @@ import type { AxisChartConfig, ChartMark } from './types'
|
|
|
9
9
|
|
|
10
10
|
const tokens: ChartTokens = {
|
|
11
11
|
categorical: ['#111111', '#222222', '#333333'],
|
|
12
|
-
sequential: ['#000011', '#000022', '#000033'
|
|
12
|
+
sequential: ['#000011', '#000022', '#000033'],
|
|
13
13
|
diverging: ['#001100', '#002200', '#003300'],
|
|
14
14
|
axisLabel: 'ink-5',
|
|
15
15
|
axisTitle: 'ink-7',
|
|
@@ -6,7 +6,7 @@ import type { DonutChartConfig } from './types'
|
|
|
6
6
|
|
|
7
7
|
const tokens: ChartTokens = {
|
|
8
8
|
categorical: ['#111111', '#222222', '#333333'],
|
|
9
|
-
sequential: ['#000011', '#000022', '#000033'
|
|
9
|
+
sequential: ['#000011', '#000022', '#000033'],
|
|
10
10
|
diverging: ['#001100', '#002200', '#003300'],
|
|
11
11
|
axisLabel: 'ink-5',
|
|
12
12
|
axisTitle: 'ink-7',
|
|
@@ -203,18 +203,10 @@ describe('buildHeatmapMatrix', () => {
|
|
|
203
203
|
})
|
|
204
204
|
|
|
205
205
|
describe('heatmapRampStops', () => {
|
|
206
|
-
it('reverses the sequential ramp
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
'#cccccc',
|
|
211
|
-
'#aaaaaa',
|
|
212
|
-
'#8a8a8a',
|
|
213
|
-
'#5a5a5a',
|
|
214
|
-
'#3a3a3a',
|
|
215
|
-
'#1a1a1a',
|
|
216
|
-
'#0a0a0a',
|
|
217
|
-
])
|
|
206
|
+
it('reverses the sequential ramp, palest first', () => {
|
|
207
|
+
expect(heatmapRampStops(config(), tokens)).toEqual(
|
|
208
|
+
[...tokens.sequential].reverse(),
|
|
209
|
+
)
|
|
218
210
|
})
|
|
219
211
|
|
|
220
212
|
it('takes the diverging ramp as authored, cool end first', () => {
|
|
@@ -347,7 +339,9 @@ describe('buildHeatmapOption', () => {
|
|
|
347
339
|
const resting = cells[index].color
|
|
348
340
|
const hovered = item.emphasis.itemStyle.color
|
|
349
341
|
expect(hovered).toBe(hoverCellColor(resting))
|
|
350
|
-
expect(hexToOklch(hovered).l).
|
|
342
|
+
expect(hexToOklch(hovered).l).toBeGreaterThanOrEqual(
|
|
343
|
+
hexToOklch(resting).l,
|
|
344
|
+
)
|
|
351
345
|
})
|
|
352
346
|
})
|
|
353
347
|
})
|
|
@@ -5,7 +5,7 @@ import type { AxisChartConfig } from './types'
|
|
|
5
5
|
|
|
6
6
|
const tokens: ChartTokens = {
|
|
7
7
|
categorical: ['#111111', '#222222', '#333333'],
|
|
8
|
-
sequential: ['#000011', '#000022', '#000033'
|
|
8
|
+
sequential: ['#000011', '#000022', '#000033'],
|
|
9
9
|
diverging: ['#001100', '#002200', '#003300'],
|
|
10
10
|
axisLabel: 'ink-5',
|
|
11
11
|
axisTitle: 'ink-7',
|
package/src/charts/style.css
CHANGED
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
The red family sits at hue 17. It was authored at 5, a crimson, whose light
|
|
26
26
|
partner lightens into a pink rather than into a warning — so anything
|
|
27
27
|
reading the ramp for a status had to leave it. 17 keeps the family clear of
|
|
28
|
-
the amber at 75 while its light partner still reads as red.
|
|
28
|
+
the amber at 75 while its light partner still reads as red. Its chroma
|
|
29
|
+
sits at 0.155, under the cap, because the eye weights red above every
|
|
30
|
+
other hue and at the cap a slice of it reads as an alert. Lower still and
|
|
31
|
+
it turns brown. */
|
|
29
32
|
--chart-categorical-1: #2283c3;
|
|
30
33
|
--chart-categorical-2: #84c5f9;
|
|
31
34
|
--chart-categorical-3: #289e60;
|
|
@@ -34,8 +37,8 @@
|
|
|
34
37
|
--chart-categorical-6: #bb9df1;
|
|
35
38
|
--chart-categorical-7: #c98c28;
|
|
36
39
|
--chart-categorical-8: #f5ca8e;
|
|
37
|
-
--chart-categorical-9: #
|
|
38
|
-
--chart-categorical-10: #
|
|
40
|
+
--chart-categorical-9: #c54b58;
|
|
41
|
+
--chart-categorical-10: #fca0a4;
|
|
39
42
|
|
|
40
43
|
--chart-sequential-1: #095895;
|
|
41
44
|
--chart-sequential-2: #2283c3;
|
|
@@ -95,8 +98,8 @@
|
|
|
95
98
|
--chart-categorical-6: #b294e7;
|
|
96
99
|
--chart-categorical-7: #bf8319;
|
|
97
100
|
--chart-categorical-8: #ebc085;
|
|
98
|
-
--chart-categorical-9: #
|
|
99
|
-
--chart-categorical-10: #
|
|
101
|
+
--chart-categorical-9: #ba4250;
|
|
102
|
+
--chart-categorical-10: #f2979b;
|
|
100
103
|
|
|
101
104
|
--chart-sequential-1: #074677;
|
|
102
105
|
--chart-sequential-2: #1b699c;
|
package/src/charts/tokens.ts
CHANGED
|
@@ -56,8 +56,8 @@ const LIGHT_CATEGORICAL = [
|
|
|
56
56
|
'#bb9df1',
|
|
57
57
|
'#c98c28',
|
|
58
58
|
'#f5ca8e',
|
|
59
|
-
'#
|
|
60
|
-
'#
|
|
59
|
+
'#c54b58',
|
|
60
|
+
'#fca0a4',
|
|
61
61
|
]
|
|
62
62
|
|
|
63
63
|
const LIGHT_SEQUENTIAL = [
|
|
@@ -95,8 +95,8 @@ const DARK_CATEGORICAL = [
|
|
|
95
95
|
'#b294e7',
|
|
96
96
|
'#bf8319',
|
|
97
97
|
'#ebc085',
|
|
98
|
-
'#
|
|
99
|
-
'#
|
|
98
|
+
'#ba4250',
|
|
99
|
+
'#f2979b',
|
|
100
100
|
]
|
|
101
101
|
|
|
102
102
|
const DARK_SEQUENTIAL = [
|
|
@@ -277,10 +277,6 @@ export function pickSeriesColor(ramp: string[], index: number) {
|
|
|
277
277
|
return ramp[index % ramp.length]
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
/** The two palest sequential stops vanish against a white card. */
|
|
281
|
-
const SEQUENTIAL_TAIL_TRIM = 2
|
|
282
|
-
/** A lone series reads best as one confident mid-blue, not the ramp's dark end. */
|
|
283
|
-
const SEQUENTIAL_SOLO_INDEX = 1
|
|
284
280
|
/**
|
|
285
281
|
* Below this relative luminance a fill needs white text on top of it. It is the
|
|
286
282
|
* crossover, not a taste call: white and the near-black inside-label ink
|
|
@@ -290,23 +286,57 @@ const SEQUENTIAL_SOLO_INDEX = 1
|
|
|
290
286
|
const DARK_FILL_LUMINANCE = 0.22
|
|
291
287
|
|
|
292
288
|
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
289
|
+
* How much of the sequential ramp a chart spends before it must widen. The
|
|
290
|
+
* last two stops of a nine-stop ramp are pale enough that a series in them is
|
|
291
|
+
* hard to read against a card, so a chart stays inside the first seven until
|
|
292
|
+
* it has more series than that.
|
|
296
293
|
*/
|
|
297
|
-
|
|
298
|
-
|
|
294
|
+
const SEQUENTIAL_SPAN = 7
|
|
295
|
+
/**
|
|
296
|
+
* The most stops apart two neighbouring series sit. Without it two series
|
|
297
|
+
* take the two ends of the span, and a third then recolors the second. With
|
|
298
|
+
* it a chart of one, two or three series is a prefix of the next.
|
|
299
|
+
*/
|
|
300
|
+
const SEQUENTIAL_MAX_STEP = 3
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* `count` stops from the deep end of a sequential ramp: spread evenly over the
|
|
304
|
+
* first `SEQUENTIAL_SPAN` stops, never more than `SEQUENTIAL_MAX_STEP` apart,
|
|
305
|
+
* widening past the span only for a chart with more series than it holds.
|
|
306
|
+
* Cycles once there are more series than stops.
|
|
307
|
+
*/
|
|
308
|
+
function sequentialStops(ramp: string[], count: number): string[] {
|
|
309
|
+
if (count === 1) return [ramp[0]]
|
|
310
|
+
if (count > ramp.length) {
|
|
299
311
|
return Array.from({ length: count }, (_, i) => pickSeriesColor(ramp, i))
|
|
300
312
|
}
|
|
313
|
+
const lastIndex = Math.min(Math.max(SEQUENTIAL_SPAN, count), ramp.length) - 1
|
|
301
314
|
return Array.from(
|
|
302
315
|
{ length: count },
|
|
303
|
-
(_, i) =>
|
|
316
|
+
(_, i) =>
|
|
317
|
+
ramp[
|
|
318
|
+
Math.min(
|
|
319
|
+
Math.round((i * lastIndex) / (count - 1)),
|
|
320
|
+
i * SEQUENTIAL_MAX_STEP,
|
|
321
|
+
)
|
|
322
|
+
],
|
|
304
323
|
)
|
|
305
324
|
}
|
|
306
325
|
|
|
307
|
-
/**
|
|
308
|
-
|
|
309
|
-
|
|
326
|
+
/**
|
|
327
|
+
* `count` evenly spaced stops across the whole of a diverging ramp, which is
|
|
328
|
+
* read by its extremes. Cycles once there are more series than stops.
|
|
329
|
+
*/
|
|
330
|
+
function divergingStops(ramp: string[], count: number): string[] {
|
|
331
|
+
if (count === 1) return [ramp[0]]
|
|
332
|
+
if (count > ramp.length) {
|
|
333
|
+
return Array.from({ length: count }, (_, i) => pickSeriesColor(ramp, i))
|
|
334
|
+
}
|
|
335
|
+
const lastIndex = ramp.length - 1
|
|
336
|
+
return Array.from(
|
|
337
|
+
{ length: count },
|
|
338
|
+
(_, i) => ramp[Math.round((i * lastIndex) / (count - 1))],
|
|
339
|
+
)
|
|
310
340
|
}
|
|
311
341
|
|
|
312
342
|
/**
|
|
@@ -328,16 +358,9 @@ export function paletteColors(
|
|
|
328
358
|
|
|
329
359
|
const ramp = name === 'diverging' ? tokens.diverging : tokens.sequential
|
|
330
360
|
if (!ramp.length) return cycle(tokens.categorical)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
return [ramp[Math.min(SEQUENTIAL_SOLO_INDEX, ramp.length - 1)]]
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// A diverging ramp is read by its extremes, so it always spans end to end.
|
|
338
|
-
const lastIndex =
|
|
339
|
-
name === 'diverging' ? ramp.length - 1 : usableSequential(ramp).length - 1
|
|
340
|
-
return rampStops(ramp, count, lastIndex)
|
|
361
|
+
return name === 'diverging'
|
|
362
|
+
? divergingStops(ramp, count)
|
|
363
|
+
: sequentialStops(ramp, count)
|
|
341
364
|
}
|
|
342
365
|
|
|
343
366
|
/**
|
|
@@ -347,10 +370,7 @@ export function paletteColors(
|
|
|
347
370
|
*/
|
|
348
371
|
function namedRamp(name: ChartPaletteName, tokens: ChartTokens): string[] {
|
|
349
372
|
if (name === 'categorical') return tokens.categorical
|
|
350
|
-
const ramp =
|
|
351
|
-
name === 'diverging'
|
|
352
|
-
? tokens.diverging
|
|
353
|
-
: usableSequential(tokens.sequential)
|
|
373
|
+
const ramp = name === 'diverging' ? tokens.diverging : tokens.sequential
|
|
354
374
|
return ramp.length ? ramp : tokens.categorical
|
|
355
375
|
}
|
|
356
376
|
|
|
@@ -359,4 +359,40 @@ describe('slot-derived computeds follow the slots', () => {
|
|
|
359
359
|
cy.get('[data-cy="toggle"]').click()
|
|
360
360
|
cy.get('[data-cy="mark"]').should('not.exist')
|
|
361
361
|
})
|
|
362
|
+
|
|
363
|
+
it('Pill does not reserve space for a conditional empty suffix', () => {
|
|
364
|
+
const Harness = defineComponent({
|
|
365
|
+
setup() {
|
|
366
|
+
const show = ref(false)
|
|
367
|
+
return () =>
|
|
368
|
+
h('div', [
|
|
369
|
+
h(
|
|
370
|
+
'button',
|
|
371
|
+
{
|
|
372
|
+
'data-cy': 'toggle',
|
|
373
|
+
onClick: () => (show.value = !show.value),
|
|
374
|
+
},
|
|
375
|
+
'Toggle',
|
|
376
|
+
),
|
|
377
|
+
h(
|
|
378
|
+
Pill,
|
|
379
|
+
{ label: 'Discussions' },
|
|
380
|
+
{
|
|
381
|
+
suffix: () =>
|
|
382
|
+
show.value ? h('span', { 'data-cy': 'count' }, '2') : null,
|
|
383
|
+
},
|
|
384
|
+
),
|
|
385
|
+
])
|
|
386
|
+
},
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
cy.mount(Harness)
|
|
390
|
+
cy.get('[data-slot="tab-suffix"]')
|
|
391
|
+
.should('have.class', 'empty:hidden')
|
|
392
|
+
.and('not.be.visible')
|
|
393
|
+
|
|
394
|
+
cy.get('[data-cy="toggle"]').click()
|
|
395
|
+
cy.get('[data-slot="tab-suffix"]').should('be.visible')
|
|
396
|
+
cy.get('[data-cy="count"]').should('have.text', '2')
|
|
397
|
+
})
|
|
362
398
|
})
|
|
@@ -104,7 +104,6 @@ const iconClass = computed(() => {
|
|
|
104
104
|
function hasLabel(label: PillProps['label']) {
|
|
105
105
|
return label !== undefined && label !== null && label !== ''
|
|
106
106
|
}
|
|
107
|
-
|
|
108
107
|
</script>
|
|
109
108
|
|
|
110
109
|
<template>
|
|
@@ -115,7 +114,7 @@ function hasLabel(label: PillProps['label']) {
|
|
|
115
114
|
<span
|
|
116
115
|
v-if="$slots.prefix"
|
|
117
116
|
data-slot="tab-prefix"
|
|
118
|
-
class="inline-flex items-center"
|
|
117
|
+
class="inline-flex items-center empty:hidden"
|
|
119
118
|
>
|
|
120
119
|
<slot name="prefix" />
|
|
121
120
|
</span>
|
|
@@ -131,7 +130,7 @@ function hasLabel(label: PillProps['label']) {
|
|
|
131
130
|
<span
|
|
132
131
|
v-if="$slots.suffix"
|
|
133
132
|
data-slot="tab-suffix"
|
|
134
|
-
class="inline-flex items-center"
|
|
133
|
+
class="inline-flex items-center empty:hidden"
|
|
135
134
|
>
|
|
136
135
|
<slot name="suffix" />
|
|
137
136
|
</span>
|
|
@@ -158,6 +158,36 @@ describe('media node view in read mode', () => {
|
|
|
158
158
|
})
|
|
159
159
|
})
|
|
160
160
|
|
|
161
|
+
describe('media node view native video fullscreen', () => {
|
|
162
|
+
it('hides editing chrome without restyling the inline container', async () => {
|
|
163
|
+
const ctx = mount(
|
|
164
|
+
'<p><video src="/files/clip.mp4" data-caption="Release demo"></video></p>',
|
|
165
|
+
)
|
|
166
|
+
await settle()
|
|
167
|
+
|
|
168
|
+
const video = ctx.root.querySelector('video') as HTMLVideoElement
|
|
169
|
+
const container = video.closest(
|
|
170
|
+
'[data-video-fullscreen-root]',
|
|
171
|
+
) as HTMLElement
|
|
172
|
+
expect(captionInputs(ctx.root)).toHaveLength(1)
|
|
173
|
+
|
|
174
|
+
video.dispatchEvent(new Event('webkitbeginfullscreen'))
|
|
175
|
+
await settle()
|
|
176
|
+
|
|
177
|
+
expect(captionInputs(ctx.root)).toHaveLength(0)
|
|
178
|
+
expect(container.classList).not.toContain('bg-black')
|
|
179
|
+
expect(video.classList).toContain('rounded-4')
|
|
180
|
+
expect(video.classList).not.toContain('rounded-none')
|
|
181
|
+
expect(video.classList).not.toContain('size-full')
|
|
182
|
+
|
|
183
|
+
video.dispatchEvent(new Event('webkitendfullscreen'))
|
|
184
|
+
await settle()
|
|
185
|
+
expect(captionInputs(ctx.root)).toHaveLength(1)
|
|
186
|
+
|
|
187
|
+
ctx.app.unmount()
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
161
191
|
describe('media node view reuse', () => {
|
|
162
192
|
it('does not carry a caption over to the next image', async () => {
|
|
163
193
|
const ctx = mount(
|
|
@@ -54,22 +54,25 @@ const isEditable = useNodeViewEditable(editor)
|
|
|
54
54
|
const isVideo = computed(() => props.node.type.name === 'video')
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* Whether THIS media is
|
|
57
|
+
* Whether THIS media is fullscreen through either browser API.
|
|
58
58
|
*
|
|
59
59
|
* The browser stretches the fullscreen element to the viewport with UA
|
|
60
60
|
* `!important` rules, but everything inside it kept the committed pixel size:
|
|
61
61
|
* the video stayed small at the top, its playback bar pinned under it, with a
|
|
62
62
|
* black slab filling the rest of the screen. In fullscreen the media is
|
|
63
63
|
* centered in the black field and the controls run along the bottom of the
|
|
64
|
-
* screen
|
|
65
|
-
*
|
|
64
|
+
* screen. iPhone instead moves the video into its native player and leaves the
|
|
65
|
+
* in-page container alone. Editing chrome (toolbar, resize handles, caption)
|
|
66
|
+
* is not rendered in either mode, since none of it is actionable there.
|
|
66
67
|
*/
|
|
67
68
|
const isFullscreen = ref(false)
|
|
69
|
+
const isStandardFullscreen = ref(false)
|
|
68
70
|
|
|
69
71
|
function syncFullscreen(): void {
|
|
70
|
-
|
|
72
|
+
isStandardFullscreen.value =
|
|
71
73
|
containerRef.value !== null &&
|
|
72
74
|
document.fullscreenElement === containerRef.value
|
|
75
|
+
isFullscreen.value = isStandardFullscreen.value
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
onMounted(() => document.addEventListener('fullscreenchange', syncFullscreen))
|
|
@@ -311,7 +314,7 @@ function setVideoOptions(options: {
|
|
|
311
314
|
ref="containerRef"
|
|
312
315
|
class="group relative isolate overflow-hidden not-prose rounded-4"
|
|
313
316
|
:class="
|
|
314
|
-
|
|
317
|
+
isStandardFullscreen
|
|
315
318
|
? 'flex items-center justify-center bg-black'
|
|
316
319
|
: containerClasses(node.attrs, selected)
|
|
317
320
|
"
|
|
@@ -322,7 +325,8 @@ function setVideoOptions(options: {
|
|
|
322
325
|
v-if="isUploaded || fileContent || node.attrs.loading"
|
|
323
326
|
class="relative"
|
|
324
327
|
:class="
|
|
325
|
-
|
|
328
|
+
isStandardFullscreen &&
|
|
329
|
+
'flex h-full w-full items-center justify-center'
|
|
326
330
|
"
|
|
327
331
|
>
|
|
328
332
|
<img
|
|
@@ -356,7 +360,7 @@ function setVideoOptions(options: {
|
|
|
356
360
|
// committed size the width/height attributes carry. `!outline-none`
|
|
357
361
|
// drops the selected-node outline from `style.css`, which would
|
|
358
362
|
// otherwise frame the video mid-screen.
|
|
359
|
-
|
|
363
|
+
isStandardFullscreen &&
|
|
360
364
|
'size-full rounded-none object-contain !outline-none',
|
|
361
365
|
]"
|
|
362
366
|
:src="node.attrs.src || fileContent"
|
|
@@ -371,9 +375,10 @@ function setVideoOptions(options: {
|
|
|
371
375
|
|
|
372
376
|
<VideoControls
|
|
373
377
|
v-if="isVideo && isUploaded"
|
|
378
|
+
v-model:fullscreen="isFullscreen"
|
|
374
379
|
:video-el="mediaRef as HTMLVideoElement | null"
|
|
375
380
|
:hidden="isResizing"
|
|
376
|
-
:fullscreen="
|
|
381
|
+
:standard-fullscreen="isStandardFullscreen"
|
|
377
382
|
/>
|
|
378
383
|
|
|
379
384
|
<MediaToolbar
|
|
@@ -30,6 +30,7 @@ beforeEach(() => {
|
|
|
30
30
|
|
|
31
31
|
afterEach(() => {
|
|
32
32
|
vi.unstubAllGlobals()
|
|
33
|
+
Reflect.deleteProperty(document, 'fullscreenEnabled')
|
|
33
34
|
})
|
|
34
35
|
|
|
35
36
|
/** Run one animation frame, the way the browser would. */
|
|
@@ -39,15 +40,33 @@ async function paint() {
|
|
|
39
40
|
await nextTick()
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
function mount(
|
|
43
|
+
function mount(
|
|
44
|
+
videoEl: HTMLVideoElement,
|
|
45
|
+
options: {
|
|
46
|
+
fullscreen?: boolean
|
|
47
|
+
standardFullscreen?: boolean
|
|
48
|
+
onUpdateFullscreen?: (fullscreen: boolean) => void
|
|
49
|
+
} = {},
|
|
50
|
+
) {
|
|
43
51
|
const root = document.createElement('div')
|
|
44
52
|
document.body.appendChild(root)
|
|
45
|
-
const app = createApp({
|
|
53
|
+
const app = createApp({
|
|
54
|
+
render: () =>
|
|
55
|
+
h(VideoControls, {
|
|
56
|
+
videoEl,
|
|
57
|
+
fullscreen: options.fullscreen,
|
|
58
|
+
standardFullscreen: options.standardFullscreen,
|
|
59
|
+
'onUpdate:fullscreen': options.onUpdateFullscreen,
|
|
60
|
+
}),
|
|
61
|
+
})
|
|
46
62
|
app.mount(root)
|
|
47
63
|
return {
|
|
48
64
|
root,
|
|
49
65
|
fill: () => root.querySelector<HTMLElement>('[aria-label="Seek"] div div'),
|
|
50
66
|
slider: () => root.querySelector<HTMLElement>('[aria-label="Seek"]'),
|
|
67
|
+
controls: () => root.firstElementChild as HTMLElement,
|
|
68
|
+
fullscreenButton: () =>
|
|
69
|
+
root.querySelector<HTMLButtonElement>('[aria-label="Fullscreen"]'),
|
|
51
70
|
unmount: () => {
|
|
52
71
|
app.unmount()
|
|
53
72
|
root.remove()
|
|
@@ -117,3 +136,100 @@ describe('VideoControls playhead', () => {
|
|
|
117
136
|
ctx.unmount()
|
|
118
137
|
})
|
|
119
138
|
})
|
|
139
|
+
|
|
140
|
+
describe('VideoControls fullscreen', () => {
|
|
141
|
+
it('fullscreens the media wrapper with the standard API', () => {
|
|
142
|
+
const video = fakeVideo()
|
|
143
|
+
const wrapper = document.createElement('div')
|
|
144
|
+
wrapper.dataset.videoFullscreenRoot = ''
|
|
145
|
+
wrapper.appendChild(video)
|
|
146
|
+
const requestFullscreen = vi.fn(() => Promise.resolve())
|
|
147
|
+
Object.defineProperty(wrapper, 'requestFullscreen', {
|
|
148
|
+
value: requestFullscreen,
|
|
149
|
+
})
|
|
150
|
+
const ctx = mount(video)
|
|
151
|
+
|
|
152
|
+
ctx.fullscreenButton()?.click()
|
|
153
|
+
|
|
154
|
+
expect(requestFullscreen).toHaveBeenCalledOnce()
|
|
155
|
+
ctx.unmount()
|
|
156
|
+
wrapper.remove()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('uses native video fullscreen when the standard API is disabled', () => {
|
|
160
|
+
const video = fakeVideo() as HTMLVideoElement & {
|
|
161
|
+
webkitSupportsFullscreen: boolean
|
|
162
|
+
webkitEnterFullscreen: () => void
|
|
163
|
+
}
|
|
164
|
+
const wrapper = document.createElement('div')
|
|
165
|
+
wrapper.dataset.videoFullscreenRoot = ''
|
|
166
|
+
wrapper.appendChild(video)
|
|
167
|
+
const requestFullscreen = vi.fn(() => Promise.resolve())
|
|
168
|
+
Object.defineProperty(wrapper, 'requestFullscreen', {
|
|
169
|
+
value: requestFullscreen,
|
|
170
|
+
})
|
|
171
|
+
Object.defineProperty(document, 'fullscreenEnabled', {
|
|
172
|
+
configurable: true,
|
|
173
|
+
value: false,
|
|
174
|
+
})
|
|
175
|
+
video.webkitSupportsFullscreen = true
|
|
176
|
+
video.webkitEnterFullscreen = vi.fn()
|
|
177
|
+
const ctx = mount(video)
|
|
178
|
+
|
|
179
|
+
ctx.fullscreenButton()?.click()
|
|
180
|
+
|
|
181
|
+
expect(requestFullscreen).not.toHaveBeenCalled()
|
|
182
|
+
expect(video.webkitEnterFullscreen).toHaveBeenCalledOnce()
|
|
183
|
+
ctx.unmount()
|
|
184
|
+
wrapper.remove()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('handles a rejected standard fullscreen request', async () => {
|
|
188
|
+
const video = fakeVideo()
|
|
189
|
+
const requestFullscreen = vi.fn(() =>
|
|
190
|
+
Promise.reject(new DOMException('Fullscreen denied', 'NotAllowedError')),
|
|
191
|
+
)
|
|
192
|
+
Object.defineProperty(video, 'requestFullscreen', {
|
|
193
|
+
value: requestFullscreen,
|
|
194
|
+
})
|
|
195
|
+
const ctx = mount(video)
|
|
196
|
+
|
|
197
|
+
ctx.fullscreenButton()?.click()
|
|
198
|
+
await Promise.resolve()
|
|
199
|
+
|
|
200
|
+
expect(requestFullscreen).toHaveBeenCalledOnce()
|
|
201
|
+
ctx.unmount()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('reports native WebKit fullscreen transitions', () => {
|
|
205
|
+
const video = fakeVideo()
|
|
206
|
+
const onUpdateFullscreen = vi.fn()
|
|
207
|
+
const ctx = mount(video, { onUpdateFullscreen })
|
|
208
|
+
|
|
209
|
+
video.dispatchEvent(new Event('webkitbeginfullscreen'))
|
|
210
|
+
video.dispatchEvent(new Event('webkitendfullscreen'))
|
|
211
|
+
|
|
212
|
+
expect(onUpdateFullscreen).toHaveBeenNthCalledWith(1, true)
|
|
213
|
+
expect(onUpdateFullscreen).toHaveBeenNthCalledWith(2, false)
|
|
214
|
+
ctx.unmount()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('keeps inline spacing for native fullscreen', () => {
|
|
218
|
+
const native = mount(fakeVideo(), { fullscreen: true })
|
|
219
|
+
|
|
220
|
+
expect(native.controls().classList).toContain('rounded-b-4')
|
|
221
|
+
expect(native.controls().classList).not.toContain('px-6')
|
|
222
|
+
expect(
|
|
223
|
+
native.root.querySelector('[aria-label="Exit fullscreen"]'),
|
|
224
|
+
).not.toBeNull()
|
|
225
|
+
native.unmount()
|
|
226
|
+
|
|
227
|
+
const standard = mount(fakeVideo(), {
|
|
228
|
+
fullscreen: true,
|
|
229
|
+
standardFullscreen: true,
|
|
230
|
+
})
|
|
231
|
+
expect(standard.controls().classList).toContain('px-6')
|
|
232
|
+
expect(standard.controls().classList).not.toContain('rounded-b-4')
|
|
233
|
+
standard.unmount()
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -35,13 +35,24 @@ const props = defineProps<{
|
|
|
35
35
|
videoEl: HTMLVideoElement | null
|
|
36
36
|
/** Hidden entirely while true (e.g. during a resize drag). */
|
|
37
37
|
hidden?: boolean
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
* media box, so it drops the rounded corner and takes more breathing room.
|
|
41
|
-
*/
|
|
42
|
-
fullscreen?: boolean
|
|
38
|
+
/** The wrapper uses the standard API and stretches this row to the screen. */
|
|
39
|
+
standardFullscreen?: boolean
|
|
43
40
|
}>()
|
|
44
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Whether this video is fullscreen through either browser API. The node view
|
|
44
|
+
* writes standard Fullscreen API changes; WebKit native-player events write
|
|
45
|
+
* their changes here.
|
|
46
|
+
*/
|
|
47
|
+
const fullscreen = defineModel<boolean>('fullscreen', { default: false })
|
|
48
|
+
|
|
49
|
+
type WebKitVideoElement = HTMLVideoElement & {
|
|
50
|
+
readonly webkitSupportsFullscreen?: boolean
|
|
51
|
+
readonly webkitDisplayingFullscreen?: boolean
|
|
52
|
+
webkitEnterFullscreen?: () => void
|
|
53
|
+
webkitExitFullscreen?: () => void
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
const playing = ref(false)
|
|
46
57
|
const muted = ref(false)
|
|
47
58
|
const currentTime = ref(0)
|
|
@@ -97,6 +108,16 @@ function bind(el: HTMLVideoElement) {
|
|
|
97
108
|
on('loadedmetadata', () => (duration.value = el.duration || 0))
|
|
98
109
|
on('durationchange', () => (duration.value = el.duration || 0))
|
|
99
110
|
on('volumechange', () => (muted.value = el.muted))
|
|
111
|
+
// iPhone presents video through its native fullscreen player rather than
|
|
112
|
+
// the standard Fullscreen API, so `document.fullscreenElement` never moves.
|
|
113
|
+
// Write those transitions through the shared fullscreen model to keep the
|
|
114
|
+
// node view's editing chrome in sync with either implementation.
|
|
115
|
+
on('webkitbeginfullscreen', () => (fullscreen.value = true))
|
|
116
|
+
on('webkitendfullscreen', () => (fullscreen.value = false))
|
|
117
|
+
|
|
118
|
+
if ((el as WebKitVideoElement).webkitDisplayingFullscreen) {
|
|
119
|
+
fullscreen.value = true
|
|
120
|
+
}
|
|
100
121
|
}
|
|
101
122
|
|
|
102
123
|
function unbind(el: HTMLVideoElement) {
|
|
@@ -158,16 +179,67 @@ function toggleMute() {
|
|
|
158
179
|
if (el) el.muted = !el.muted
|
|
159
180
|
}
|
|
160
181
|
|
|
182
|
+
function ignoreFullscreenRejection(promise: Promise<void> | undefined) {
|
|
183
|
+
// A fullscreen request can be refused by browser policy. It is an optional
|
|
184
|
+
// presentation affordance, so a rejection must not become an unhandled app
|
|
185
|
+
// error.
|
|
186
|
+
void promise?.catch(() => {})
|
|
187
|
+
}
|
|
188
|
+
|
|
161
189
|
function toggleFullscreen() {
|
|
162
190
|
const el = props.videoEl
|
|
163
191
|
if (!el) return
|
|
192
|
+
const webkitVideo = el as WebKitVideoElement
|
|
193
|
+
|
|
194
|
+
if (webkitVideo.webkitDisplayingFullscreen) {
|
|
195
|
+
try {
|
|
196
|
+
webkitVideo.webkitExitFullscreen?.()
|
|
197
|
+
} catch {
|
|
198
|
+
// The native player may already be leaving fullscreen.
|
|
199
|
+
}
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
164
203
|
if (document.fullscreenElement) {
|
|
165
|
-
|
|
204
|
+
try {
|
|
205
|
+
ignoreFullscreenRejection(document.exitFullscreen?.())
|
|
206
|
+
} catch {
|
|
207
|
+
// Fullscreen state may have changed between the check and the request.
|
|
208
|
+
}
|
|
166
209
|
return
|
|
167
210
|
}
|
|
211
|
+
|
|
168
212
|
// Fullscreen the wrapper so these controls stay visible over the video.
|
|
169
213
|
const root = el.closest('[data-video-fullscreen-root]') ?? el
|
|
170
|
-
|
|
214
|
+
const requestFullscreen = (root as HTMLElement).requestFullscreen
|
|
215
|
+
|
|
216
|
+
// `fullscreenEnabled` is false on iPhone even though the video-specific
|
|
217
|
+
// WebKit API is available. Choose that path before making an asynchronous
|
|
218
|
+
// standard request: `webkitEnterFullscreen()` must run directly inside the
|
|
219
|
+
// click's user-activation window.
|
|
220
|
+
if (
|
|
221
|
+
typeof requestFullscreen === 'function' &&
|
|
222
|
+
document.fullscreenEnabled !== false
|
|
223
|
+
) {
|
|
224
|
+
try {
|
|
225
|
+
ignoreFullscreenRejection(requestFullscreen.call(root))
|
|
226
|
+
return
|
|
227
|
+
} catch {
|
|
228
|
+
// A synchronous standard-API failure can still fall through to WebKit
|
|
229
|
+
// while this click retains user activation.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (
|
|
234
|
+
webkitVideo.webkitSupportsFullscreen &&
|
|
235
|
+
typeof webkitVideo.webkitEnterFullscreen === 'function'
|
|
236
|
+
) {
|
|
237
|
+
try {
|
|
238
|
+
webkitVideo.webkitEnterFullscreen()
|
|
239
|
+
} catch {
|
|
240
|
+
// Native fullscreen can be unavailable until media metadata is ready.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
171
243
|
}
|
|
172
244
|
|
|
173
245
|
function timeFromPointer(event: PointerEvent): number {
|
|
@@ -267,7 +339,7 @@ const SCRIM =
|
|
|
267
339
|
class="absolute inset-x-0 bottom-0 z-20 flex items-center gap-3 text-white transition-opacity [filter:drop-shadow(0_1px_2px_rgb(0_0_0/0.45))]"
|
|
268
340
|
:style="{ backgroundImage: SCRIM }"
|
|
269
341
|
:class="[
|
|
270
|
-
|
|
342
|
+
standardFullscreen ? 'px-6 pt-16 pb-6' : 'rounded-b-4 px-3.5 pt-12 pb-3',
|
|
271
343
|
playing && !scrubbing
|
|
272
344
|
? 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'
|
|
273
345
|
: 'opacity-100',
|
|
@@ -367,14 +439,20 @@ const SCRIM =
|
|
|
367
439
|
</button>
|
|
368
440
|
</Tooltip>
|
|
369
441
|
|
|
370
|
-
<Tooltip
|
|
442
|
+
<Tooltip
|
|
443
|
+
:text="fullscreen ? 'Exit fullscreen' : 'Fullscreen'"
|
|
444
|
+
class="flex h-5"
|
|
445
|
+
>
|
|
371
446
|
<button
|
|
372
447
|
type="button"
|
|
373
448
|
class="opacity-90 transition-opacity hover:opacity-100"
|
|
374
|
-
aria-label="Fullscreen"
|
|
449
|
+
:aria-label="fullscreen ? 'Exit fullscreen' : 'Fullscreen'"
|
|
375
450
|
@click="toggleFullscreen"
|
|
376
451
|
>
|
|
377
|
-
<span
|
|
452
|
+
<span
|
|
453
|
+
:class="fullscreen ? 'lucide-minimize-2' : 'lucide-maximize-2'"
|
|
454
|
+
class="size-5"
|
|
455
|
+
/>
|
|
378
456
|
</button>
|
|
379
457
|
</Tooltip>
|
|
380
458
|
</div>
|